termdock 1.4.316 → 1.4.317

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.
@@ -23,7 +23,7 @@ export function collaborationRulesAge(updatedAt, now = Date.now()) {
23
23
  * names may carry them, so the render layer neutralizes them defensively;
24
24
  * user-entered names are rejected at creation instead.
25
25
  */
26
- export const COLLAB_NAME_FORBIDDEN = /[【】「」·\x00-\x1f\x7f]/;
26
+ export { COLLAB_NAME_FORBIDDEN } from './collaborationProtocol.js';
27
27
  /** Render-safe name for shell header / per-message source lines: reserved
28
28
  * bracket and delimiter characters become spaces (they would otherwise split
29
29
  * the `「X」群` and `来自:X · kind` lines), control characters are
@@ -1,4 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
+ /** Shared local group-name validation; presentation keeps its compatible export. */
3
+ export const COLLAB_NAME_FORBIDDEN = /[【】「」·\x00-\x1f\x7f]/;
2
4
  /** Transport facts and application reports deliberately remain separate. */
3
5
  export const COLLAB_LIMITS = {
4
6
  message_bytes: 1_048_576,
@@ -3,7 +3,7 @@ import { validateLaunchProfiles } from './collaborationLaunchProfiles.js';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
- import { COLLAB_LIMITS, CollaborationError, STATUS_RANK, canonicalShortId, drawCollaborationId, newCollaborationId, resolveIdPrefix, validateExtras } from './collaborationProtocol.js';
6
+ import { COLLAB_NAME_FORBIDDEN, COLLAB_LIMITS, CollaborationError, STATUS_RANK, canonicalShortId, drawCollaborationId, newCollaborationId, resolveIdPrefix, validateExtras } from './collaborationProtocol.js';
7
7
  function newerRevision(a, b) {
8
8
  return !b || a.updatedAt > b.updatedAt || (a.updatedAt === b.updatedAt && a.version > b.version);
9
9
  }
@@ -64,9 +64,10 @@ export class CollaborationStore {
64
64
  activity_observed_at: typeof observedAt === 'number' && Number.isFinite(observedAt) ? observedAt : null,
65
65
  last_peer_sync_at: Date.now(), activity_source: 'remote_terminal_output' });
66
66
  }
67
- document = { version: 2, groups: [], messages: [], terminalHistoryComplete: true };
67
+ document = { version: 2, groups: [], messages: [], terminalHistoryComplete: true, localGroupRequestsComplete: true };
68
68
  persistedDocument = JSON.stringify(this.document);
69
69
  historyAvailable = true;
70
+ groupCreationAvailable = true;
70
71
  /** `drawId` is the random source behind newCollaborationId. It is a
71
72
  * constructor seam so tests can force a collision and prove the guard
72
73
  * below actually skips it; production always uses the CSPRNG default. */
@@ -94,6 +95,14 @@ export class CollaborationStore {
94
95
  this.document.sequence = Math.max(this.document.sequence ?? 0, message.sequence);
95
96
  }
96
97
  }
98
+ const requests = parsed.localGroupRequests;
99
+ this.groupCreationAvailable = [1, 2].includes(parsed.version ?? 0) && Array.isArray(parsed.groups)
100
+ && (parsed.localGroupRequestsComplete === undefined || parsed.localGroupRequestsComplete === true) && (requests === undefined || requests !== null && typeof requests === 'object' && !Array.isArray(requests)
101
+ && Object.entries(requests).every(([key, value]) => /^[a-f0-9]{64}$/.test(key) && value && /^[a-f0-9]{64}$/.test(value.hash)
102
+ && value.group && typeof value.group.id === 'string' && typeof value.group.name === 'string'
103
+ && Array.isArray(value.group.sessionIds) && value.group.sessionIds.length === 0 && value.group.federated === false
104
+ && Number.isSafeInteger(value.group.createdAt) && Number.isSafeInteger(value.group.updatedAt)));
105
+ this.document.localGroupRequests = this.groupCreationAvailable ? requests ?? {} : {};
97
106
  this.document.sequence = Math.max(parsed.sequence ?? 0, this.document.sequence ?? 0);
98
107
  this.document.idempotency = parsed.idempotency ?? {};
99
108
  this.document.consumers = parsed.consumers ?? {};
@@ -104,8 +113,10 @@ export class CollaborationStore {
104
113
  }
105
114
  catch (error) {
106
115
  this.historyAvailable = error.code === 'ENOENT';
116
+ this.groupCreationAvailable = this.historyAvailable;
107
117
  }
108
118
  this.document.terminalHistoryComplete = this.historyAvailable;
119
+ this.document.localGroupRequestsComplete = this.groupCreationAvailable;
109
120
  this.persistedDocument = JSON.stringify(this.document);
110
121
  }
111
122
  list() {
@@ -170,6 +181,39 @@ export class CollaborationStore {
170
181
  this.persist();
171
182
  return group;
172
183
  }
184
+ /** The administrator request and empty local group share one durable commit.
185
+ * No terminal identity, arbitrary membership, or federation is involved. */
186
+ createEmptyLocalGroup(value, key) {
187
+ if (typeof value !== 'string' || !value.trim() || value.length > 256 || COLLAB_NAME_FORBIDDEN.test(value)) {
188
+ throw new CollaborationError('INVALID_GROUP', 'name must be non-empty text up to 256 characters without forbidden group-name characters', 400);
189
+ }
190
+ if (typeof key !== 'string' || !key.trim() || key.length > 256)
191
+ throw new CollaborationError('INVALID_IDEMPOTENCY_KEY', 'An explicit 1..256 character idempotency key is required', 400);
192
+ if (!this.groupCreationAvailable)
193
+ throw new CollaborationError('GROUP_CREATION_STATE_UNCONFIRMED', 'Creation journal is unavailable; reconcile the persisted state before creating another group', 409);
194
+ const name = value.trim(), requestKey = crypto.createHash('sha256').update(key).digest('hex');
195
+ const hash = crypto.createHash('sha256').update(JSON.stringify(['empty-local-group', name])).digest('hex');
196
+ const previous = this.document.localGroupRequests?.[requestKey];
197
+ if (previous) {
198
+ if (previous.hash !== hash)
199
+ throw new CollaborationError('IDEMPOTENCY_CONFLICT', 'This administrator key was used for different group parameters', 409);
200
+ const current = this.getGroup(previous.group.id);
201
+ if (!current || current.deleted || current.federated)
202
+ throw new CollaborationError('GROUP_CREATE_TARGET_UNAVAILABLE', 'Original local group was deleted or changed; this key cannot create a replacement', 409);
203
+ return structuredClone(previous.group);
204
+ }
205
+ if (Object.keys(this.document.localGroupRequests ?? {}).length >= 2000)
206
+ throw new CollaborationError('GROUP_CREATE_LIMIT', 'Administrator group creation journal has reached its 2000-record limit', 409);
207
+ const now = Date.now(), group = { id: newCollaborationId(this.takenIds(), this.drawId), name,
208
+ sessionIds: [], federated: false, createdAt: now, updatedAt: now };
209
+ this.document = { ...this.document, groups: [...this.document.groups, group], localGroupRequests: {
210
+ ...this.document.localGroupRequests, [requestKey]: { hash, group: structuredClone(group) }
211
+ } };
212
+ // persist restores the last committed document on failure. A post-rename
213
+ // fsync failure retains the committed request so retries cannot duplicate.
214
+ this.persist(true);
215
+ return structuredClone(group);
216
+ }
173
217
  setMemberLaunchProfile(groupId, sessionId, profile) {
174
218
  const group = this.getGroup(groupId);
175
219
  if (!group || group.deleted || !group.sessionIds.includes(sessionId))
@@ -326,7 +370,7 @@ export class CollaborationStore {
326
370
  clear() {
327
371
  if (this.document.groups.length === 0 && this.document.messages.length === 0)
328
372
  return;
329
- this.document = { version: 2, groups: this.document.groups
373
+ this.document = { version: 2, localGroupRequests: this.document.localGroupRequests, localGroupRequestsComplete: this.groupCreationAvailable, groups: this.document.groups
330
374
  .map((group) => group.deleted ? group : ({ ...group, deleted: true, updatedAt: Math.max(Date.now(), group.updatedAt + 1) })), messages: [] };
331
375
  this.persist();
332
376
  }
@@ -8,6 +8,18 @@ import { COLLAB_HELP, executeCollaborationCommand, duration } from './collaborat
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
+ group create --name <name> --idempotency-key <key>
12
+ Administrator only; create an empty local group without a terminal source.
13
+ Minimum CLI/running service 1.4.317; admin_local_group_create=true.
14
+ Returns {group: original creation snapshot}; same key/parameters reuse it.
15
+ Groups may later gain members; the snapshot is not their current membership.
16
+ No IDs, members, federation, launch profiles or principal credentials accepted.
17
+ Deleted/changed targets are not recreated; preserve the key after uncertain results.
18
+ Errors: INVALID_GROUP, INVALID_IDEMPOTENCY_KEY, IDEMPOTENCY_CONFLICT,
19
+ GROUP_CREATE_TARGET_UNAVAILABLE, GROUP_CREATION_STATE_UNCONFIRMED,
20
+ GROUP_CREATE_LIMIT (2000 retained creation records), ADMIN_LOCAL_GROUP_CREATE_UNSUPPORTED.
21
+ Next authorize a new fixed-group principal using integration create; no empty
22
+ administrator terminal is required. Existing principal/session ownership is unchanged.
11
23
  clear-startup-input <TD-session-id> --generation <N>
12
24
  Administrator removes this session's saved screen conditions and disables
13
25
  inheritance on later restores; original identity/launch/history are preserved.
@@ -89,6 +101,24 @@ export async function runIntegrationAdmin(argv, port, adminToken, write) {
89
101
  return 0;
90
102
  }
91
103
  const socketPath = integrationSocketPath(port);
104
+ if (action === 'group') {
105
+ const args = {};
106
+ const usage = 'Usage: td integration group create --name <name> --idempotency-key <key>';
107
+ if (argv[1] !== 'create')
108
+ throw new CollaborationError('INVALID_GROUP_CREATE_REQUEST', usage, 400);
109
+ for (let i = 2; i < argv.length; i += 2) {
110
+ if (!['--name', '--idempotency-key'].includes(argv[i]) || !argv[i + 1] || Object.hasOwn(args, argv[i]))
111
+ throw new CollaborationError('INVALID_GROUP_CREATE_REQUEST', usage, 400);
112
+ args[argv[i]] = argv[i + 1];
113
+ }
114
+ if (!args['--name']?.trim() || !args['--idempotency-key']?.trim() || Object.keys(args).length !== 2)
115
+ throw new CollaborationError('INVALID_GROUP_CREATE_REQUEST', usage, 400);
116
+ const capabilities = parsed(await request(socketPath, 'GET', '/admin/capabilities', undefined, headers(undefined, adminToken)));
117
+ if (capabilities.admin_local_group_create !== true)
118
+ throw new CollaborationError('ADMIN_LOCAL_GROUP_CREATE_UNSUPPORTED', 'CLI and running service must support empty local group creation (minimum 1.4.317)', 409);
119
+ write(JSON.stringify(parsed(await request(socketPath, 'POST', '/admin/groups', { name: args['--name'], idempotency_key: args['--idempotency-key'] }, headers(undefined, adminToken)))));
120
+ return 0;
121
+ }
92
122
  if (action === 'clear-startup-input') {
93
123
  const id = argv[1];
94
124
  if (argv.length !== 4 || !id || !/^[a-zA-Z0-9._-]{1,80}$/.test(id) || argv[2] !== '--generation'
@@ -69,6 +69,14 @@ export class IntegrationRuntime {
69
69
  integrationError('INTEGRATION_ADMIN_REQUIRED', 'Local administration credential is required', 401);
70
70
  };
71
71
  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 })) }; }));
72
+ app.post('/admin/groups', run(req => {
73
+ admin(req);
74
+ const body = req.body;
75
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['name', 'idempotency_key'].includes(key))) {
76
+ integrationError('INVALID_GROUP_CREATE_REQUEST', 'Specify only name and idempotency_key; members, federation, IDs and launch configuration are not accepted');
77
+ }
78
+ return { group: options.messages.createEmptyLocalGroup(body.name, body.idempotency_key) };
79
+ }));
72
80
  app.get('/admin/capabilities', run(req => { admin(req); return this.capabilities(); }));
73
81
  app.get('/admin/sessions/:id/restore-diagnostics', run(req => { admin(req); return { ...this.sessions.restoreDiagnostics(req.params.id), ...this.sessions.launchDiagnostics(req.params.id) }; }));
74
82
  app.get('/admin/sessions/:id/resume-configuration', run(async (req) => { admin(req); return this.sessions.resumeConfiguration(req.params.id); }));
@@ -233,6 +241,7 @@ export class IntegrationRuntime {
233
241
  session_cold_retry: true, session_cold_retry_min_version: '1.4.314',
234
242
  session_rename: true, session_rename_min_version: '1.4.315',
235
243
  session_close: true, session_close_min_version: '1.4.315',
244
+ admin_local_group_create: true, admin_local_group_create_min_version: '1.4.317',
236
245
  integration_policy_update: true,
237
246
  session_restore_diagnostics: true,
238
247
  native_identity_linux_flock_owner: process.platform === 'linux',
@@ -1026,3 +1026,70 @@ TD 生命周期锁不排他控制外部 tmux 命令/手工进程替换,最终
1026
1026
  永久审计存储。close 不增加 task、看板或 accepted。真实私有 TraeX 与 DevBox
1027
1027
  共享升级/业务适配由接入方后续验收;维护侧验证仅覆盖隔离测试的真实 tmux/Linux
1028
1028
  进程退出与源码回归。macOS 真实 close 路径尚未实机验收。
1029
+
1030
+ ## 1.4.317:管理员幂等创建空本机组
1031
+
1032
+ 最低 **CLI / 实际服务均为 1.4.317**,协议仍为 1。管理员回读
1033
+ `admin_local_group_create=true`、`admin_local_group_create_min_version=1.4.317`;
1034
+ 旧服务缺少能力时 CLI 返回 `ADMIN_LOCAL_GROUP_CREATE_UNSUPPORTED`,不回退到终端
1035
+ 成员来源、网页裸 HTTP 或私有文件写入。
1036
+
1037
+ ```sh
1038
+ td integration group create --name 'Case A' --idempotency-key case-a:group
1039
+ # 返回 {"group":{"id":"...","name":"Case A","sessionIds":[],
1040
+ # "federated":false,"createdAt":...,"updatedAt":...}}
1041
+ td integration groups # 回读现有本机组的id/name
1042
+ # 使用返回的group.id填入新的独立policy.groupId:
1043
+ td integration create --file case-a-policy.json --credential-file case-a-credential.json
1044
+ ```
1045
+
1046
+ 本机 Unix socket 管理路由 `POST /admin/groups`,JSON 只接受
1047
+ `{name,idempotency_key}`。沿用本机管理员 credential 的鉴权与协议版本检查,由正式
1048
+ CLI 从服务状态读取管理员凭据;不要把它放在命令行、消息、日志,也不要发给 Agent。
1049
+ principal 凭据不能调用此入口(`INTEGRATION_ADMIN_REQUIRED`),没有新增 principal
1050
+ 建组权限。请求不能携带成员/目标ID/federated/launchProfiles/principal 等字段,
1051
+ 即使 `sessionIds=[]` 也属于多余参数,返回 `INVALID_GROUP_CREATE_REQUEST`。
1052
+
1053
+ 名称去除首尾空白,非空、最多 256 字符,沿用现有本机组的禁止字符规则(括号
1054
+ `【】「」`、间隔号 `·`、C0 控制字符及 DEL)。名称不作为唯一标识;不同 key 同名可以
1055
+ 建立不同组。幂等 key 必须非空、最多 256 字符,按原字符串区分;存储中只保存其哈希。
1056
+ API 名称非法返回 `INVALID_GROUP`,key 非法返回 `INVALID_IDEMPOTENCY_KEY`。
1057
+
1058
+ 创建记录和空组写入同一个协作存储文件,以一次 durable 原子替换提交,包含文件与
1059
+ 父目录 fsync。服务内同步执行,无终端/tmux/Agent 启动和正文投递;不包含 principal
1060
+ 授权、业务调度或三模型创建。**组创建与后续 principal 授权是两步操作,不是一个事务**。
1061
+ 后者继续遵循已有凭据落盘、唯一 principal ID、固定授权组和拒绝静默替换规则。
1062
+
1063
+ 同服务存储中,同 key 同参数(名称规范化后)返回原创建快照,包括原 ID、空成员和
1064
+ 原时间;同 key 异参返回 `IDEMPOTENCY_CONFLICT`。之后给原组加入成员或改名,不会改变
1065
+ 创建快照,不能把重试响应的空 `sessionIds` 当作当前成员清单;当前名称可用 groups
1066
+ 回读,成员清单沿现有组管理读接口。HTTP 回执丢失或持久写入结果不明时,保留原 key
1067
+ 和名称重试对账,不要换新 key “再建一个”。
1068
+
1069
+ 文件替换前失败不会提交半个组;替换后 fsync/回执异常可能已经创建,应同键对账。
1070
+ 服务重启保留 key→原快照映射。原组被明确删除、变成跨服务目标或原 ID 已不存在时,
1071
+ 同键返回 `GROUP_CREATE_TARGET_UNAVAILABLE`,不新建替代组;全局清理也保留该 key 的
1072
+ 保留记录。创建 journal 损坏、无法可靠读取或其完整性未确认返回
1073
+ `GROUP_CREATION_STATE_UNCONFIRMED`,普通存储写入不会自动把这种情况改称“已确认”。
1074
+ 此时管理员先对账/修复可信存储,不能通过换 key 绕过。
1075
+
1076
+ 每个服务存储最多保留 2,000 个此入口的创建记录(含已删除组的 key 保留记录),满时
1077
+ 新 key 返回 `GROUP_CREATE_LIMIT`;已有 key 仍可回读。首版无 TTL/forget,不为腾空间
1078
+ 静默删除幂等证据。这不增加会话、普通消息、事件 journal 的原保留额度;调用者负责
1079
+ 组/凭据/逻辑记录预算。保证范围是唯一运行服务写入同一持久存储;不提供多个独立
1080
+ 服务共享文件的分布式事务,不能降级到旧写入器后再假设幂等证据仍完整。
1081
+
1082
+ 推荐外部宿主按自身业务创建独立本机组,再用现有 integration create 授权独立
1083
+ principal,可复用相同的 launchProfiles 配置模板。已有组允许零活动成员,随后
1084
+ principal create/restore 不要求保留空管理终端。此能力不创建业务 Case、定时器、
1085
+ consumer、task 或看板任务;Case/组/principal/session/message 映射由宿主持久保存。
1086
+
1087
+ 已有 principal 的 groupId 仍不可修改(`INTEGRATION_GROUP_IMMUTABLE`);普通成员迁组
1088
+ 也不会迁移 IntegrationSession 的归属、旧消息/reply、事件或 ACK 游标。此入口不提供
1089
+ 旧 Case/原会话迁移。新组独立使用新授权,已有链路保持原归属完成/关闭。
1090
+
1091
+ 事件订阅协议未改变:新 consumer 从该组保留窗口最早可见位置开始,并非“从现在”
1092
+ 开始;读取不自动 ACK。当前没有公开 ready/subscribed 帧,进程启动不能当订阅完成。
1093
+ 即使普通 send 早于实际订阅建立,保留期内的事实也可重放;宿主按 event_id 持久
1094
+ 去重/连续 ACK,并按原 message ID/reply_to 用历史对账明确回复。heartbeat 仅是连接
1095
+ 观测,不表示宿主 inbox 已落盘或业务已完成。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdock",
3
- "version": "1.4.316",
3
+ "version": "1.4.317",
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.316",
4
+ "version": "1.4.317",
5
5
  "runtimeProtocolVersion": 1,
6
6
  "minimumDesktopVersion": "1.4.46",
7
7
  "nodeMajor": 22,
8
8
  "dependencyHash": "sha256-oaGYi/P5ob8sd6VnNBOwshKozjhdV+hYiCmLAsn5x00=",
9
- "serverBundleHash": "sha256-DAZwKtCVOjhre1SF4x800AfouUy22Cyfat68O+E8Hlo=",
9
+ "serverBundleHash": "sha256-1pRiIEg4Lpi8gtQSmaiVWgjK0PZrrA+QobxZC5+PEWc=",
10
10
  "clientBundleHash": "sha256-iwZyAmz8pDZptAaDUwHHnAqZlIvu6R9xpWhvQhPz3xY=",
11
11
  "entrypoint": "dist/server/cli.js",
12
12
  "clientEntrypoint": "dist/client/index.html"