@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/docs/codex-account.zh-CN.md +38 -0
  3. package/docs/codex-protocol.md +59 -0
  4. package/docs/commands.md +9 -0
  5. package/docs/commands.zh-CN.md +7 -0
  6. package/docs/macos-process-identity.md +2 -0
  7. package/docs/native.md +179 -14
  8. package/docs/native.zh-CN.md +136 -6
  9. package/docs/web-handoff.zh-CN.md +118 -8
  10. package/lib/cli/app.mjs +47 -4
  11. package/lib/cli/commands/coordination.mjs +14 -4
  12. package/lib/cli/commands/dsh.mjs +1 -1
  13. package/lib/cli/commands/gc.mjs +22 -15
  14. package/lib/cli/commands/hook.mjs +19 -3
  15. package/lib/cli/commands/install.mjs +80 -10
  16. package/lib/cli/commands/misc.mjs +36 -9
  17. package/lib/cli/commands/native.mjs +4 -38
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +13 -6
  20. package/lib/cli/commands/tmux.mjs +1 -2
  21. package/lib/cli-runtime.mjs +19 -2
  22. package/lib/core/coordination/results.mjs +27 -10
  23. package/lib/core/sessions/hook-root-identity.mjs +47 -0
  24. package/lib/core/sessions/launch.mjs +7 -1
  25. package/lib/db/connection.mjs +140 -42
  26. package/lib/db/schema.mjs +59 -0
  27. package/lib/diagnostics/codex-protocol.mjs +105 -0
  28. package/lib/guidance.mjs +21 -36
  29. package/lib/integrations/codex-account.mjs +143 -0
  30. package/lib/integrations/codex-interactions.mjs +13 -11
  31. package/lib/integrations/dsh-collaboration.mjs +6 -12
  32. package/lib/integrations/dsh-cordis.mjs +17 -13
  33. package/lib/integrations/dsh.mjs +16 -11
  34. package/lib/integrations/mcp-elicitation.mjs +116 -0
  35. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  36. package/lib/integrations/native/claude.mjs +24 -6
  37. package/lib/integrations/native/codex.mjs +38 -7
  38. package/lib/integrations/native/dsh-acp.mjs +67 -24
  39. package/lib/integrations/native/index.mjs +4 -1
  40. package/lib/integrations/native/interactions.mjs +2 -1
  41. package/lib/integrations/native/jsonrpc.mjs +37 -5
  42. package/lib/integrations/native/telemetry.mjs +30 -0
  43. package/lib/process/identity.mjs +4 -0
  44. package/lib/process/pinned-cwd.mjs +302 -0
  45. package/lib/process/pinned-guidance.mjs +138 -0
  46. package/lib/process/pinned-project-state.mjs +105 -0
  47. package/lib/process/selected-cwd-identity.mjs +95 -0
  48. package/lib/runtime/buffer-evidence.mjs +2 -1
  49. package/lib/runtime/client.mjs +9 -0
  50. package/lib/runtime/native/client.mjs +4 -1
  51. package/lib/runtime/native/history.mjs +140 -0
  52. package/lib/runtime/native/launcher.mjs +106 -0
  53. package/lib/runtime/native/service.mjs +64 -8
  54. package/lib/runtime/native/store.mjs +83 -58
  55. package/lib/runtime/paths.mjs +17 -2
  56. package/lib/runtime/private-file.mjs +76 -0
  57. package/lib/runtime/private-state.mjs +546 -0
  58. package/lib/runtime/project-context.mjs +2 -1
  59. package/lib/runtime/project-path.mjs +92 -20
  60. package/lib/runtime/project-trust.mjs +79 -0
  61. package/lib/runtime/projects.mjs +9 -3
  62. package/lib/runtime/state-migration.mjs +697 -0
  63. package/lib/runtime/state.mjs +69 -27
  64. package/lib/shared/automatic-result-key.mjs +6 -0
  65. package/lib/shared/json-file.mjs +55 -6
  66. package/lib/shared/redact.mjs +2 -1
  67. package/lib/ui/help.mjs +6 -4
  68. package/lib/web/agent-defaults.mjs +110 -0
  69. package/lib/web/auto-attach.mjs +3 -2
  70. package/lib/web/browser/contracts.d.ts +10 -0
  71. package/lib/web/browser/core.mjs +1997 -0
  72. package/lib/web/browser/project-data.mjs +158 -0
  73. package/lib/web/browser/session-sync.mjs +82 -0
  74. package/lib/web/browser-assets.mjs +19 -0
  75. package/lib/web/buffer-gc-runtime.mjs +2 -2
  76. package/lib/web/codex-app-server.mjs +111 -37
  77. package/lib/web/codex-sessions.mjs +61 -12
  78. package/lib/web/context-files.mjs +82 -0
  79. package/lib/web/cookie-auth.mjs +51 -3
  80. package/lib/web/external-sessions.mjs +76 -9
  81. package/lib/web/http-routes.mjs +238 -25
  82. package/lib/web/http.mjs +50 -4
  83. package/lib/web/liveness-reaper.mjs +2 -2
  84. package/lib/web/native-sessions.mjs +323 -14
  85. package/lib/web/project-contexts.mjs +236 -44
  86. package/lib/web/project-file-write.mjs +249 -0
  87. package/lib/web/project-files.mjs +268 -0
  88. package/lib/web/pty-sessions.mjs +11 -3
  89. package/lib/web/runtime-main.mjs +132 -80
  90. package/lib/web/session-serialize.mjs +87 -8
  91. package/lib/web/session-state-sync.mjs +98 -0
  92. package/lib/web/shutdown-diagnostics.mjs +38 -0
  93. package/lib/web/shutdown-response.mjs +21 -0
  94. package/lib/web/shutdown-state.mjs +91 -0
  95. package/lib/web/startup.mjs +103 -35
  96. package/lib/web/task-results.mjs +19 -19
  97. package/lib/web/tls.mjs +84 -33
  98. package/lib/web/tmux-sessions.mjs +73 -9
  99. package/lib/web/tmux-stream.mjs +36 -8
  100. package/lib/web/ui-agent-defaults.mjs +149 -0
  101. package/lib/web/ui-agent-start.mjs +330 -0
  102. package/lib/web/ui-codex-account.mjs +81 -0
  103. package/lib/web/ui-codex.mjs +105 -85
  104. package/lib/web/ui-command-palette.mjs +21 -5
  105. package/lib/web/ui-file-writes.mjs +220 -0
  106. package/lib/web/ui-files.mjs +231 -0
  107. package/lib/web/ui-handoff.mjs +10 -2
  108. package/lib/web/ui-history.mjs +175 -105
  109. package/lib/web/ui-i18n.mjs +280 -8
  110. package/lib/web/ui-interactions.mjs +107 -6
  111. package/lib/web/ui-native-timeline.mjs +326 -0
  112. package/lib/web/ui-native.mjs +93 -43
  113. package/lib/web/ui-review.mjs +5 -3
  114. package/lib/web/ui-safe-markdown.mjs +79 -0
  115. package/lib/web/ui-session-tools.mjs +224 -0
  116. package/lib/web/ui-template.mjs +29 -1968
  117. package/lib/web/ui-terminal-find.mjs +5 -3
  118. package/lib/web/ui-workbench.mjs +5 -3
  119. package/lib/web/ui-workspace.mjs +3 -0
  120. package/package.json +8 -2
  121. package/scripts/benchmark-session-sync.mjs +53 -0
  122. package/scripts/codex-protocol.mjs +88 -0
  123. package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
  124. package/scripts/dsh-package-provenance.mjs +118 -0
  125. package/scripts/mcp-form-live-fixture.mjs +81 -0
  126. package/scripts/native-bounded-live-acceptance.mjs +193 -0
  127. package/scripts/native-fixture-root.mjs +49 -0
  128. package/scripts/native-installed-acceptance.mjs +611 -0
  129. package/scripts/native-stability-checks.mjs +40 -0
  130. package/scripts/regression-cookie-expiry.mjs +70 -0
  131. package/scripts/regression-http-transport.mjs +9 -0
  132. package/scripts/regression-terminal-marker.mjs +95 -0
  133. package/scripts/regression.mjs +481 -219
  134. package/scripts/shim-reentry-probe.mjs +105 -0
  135. package/scripts/web-acceptance-diagnostics.mjs +90 -0
  136. package/scripts/web-codex-account-acceptance.mjs +178 -0
  137. package/scripts/web-file-preview-checks.mjs +302 -0
  138. package/scripts/web-mcp-form-acceptance.mjs +134 -0
  139. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  140. package/scripts/web-native-handoff-checks.mjs +128 -0
  141. package/scripts/web-native-interaction-acceptance.mjs +219 -18
  142. package/scripts/web-native-interaction-diagnostics.mjs +80 -0
  143. package/scripts/web-native-model-acceptance.mjs +22 -7
  144. package/scripts/web-session-tools-checks.mjs +112 -0
  145. package/scripts/web-workbench-acceptance.mjs +435 -0
  146. package/scripts/web-workbench-installed-acceptance.mjs +122 -0
@@ -0,0 +1,143 @@
1
+ // Read-only projection of official App Server account and quota data. Never
2
+ // forward emails, credential identifiers, tokens, raw errors or unknown fields.
3
+ const PLANS = new Set(['free', 'go', 'plus', 'pro', 'prolite', 'team',
4
+ 'self_serve_business_usage_based', 'business', 'enterprise_cbp_usage_based', 'enterprise', 'edu', 'unknown']);
5
+ const TYPES = new Set(['apiKey', 'chatgpt', 'amazonBedrock']);
6
+ const REASONS = new Set(['rate_limit_reached', 'workspace_owner_credits_depleted', 'workspace_member_credits_depleted',
7
+ 'workspace_owner_usage_limit_reached', 'workspace_member_usage_limit_reached']);
8
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
9
+ const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
10
+ const integer = value => Number.isSafeInteger(value) && value >= 0;
11
+ const bucketId = value => typeof value === 'string' && /^[A-Za-z0-9_.-]{1,80}$/.test(value) ? value : null;
12
+
13
+ function windowValue(value) {
14
+ if (!record(value) || !finite(value.usedPercent)) return null;
15
+ return { usedPercent: value.usedPercent,
16
+ resetsAt: integer(value.resetsAt) ? value.resetsAt : null,
17
+ windowDurationMins: integer(value.windowDurationMins) && value.windowDurationMins > 0 ? value.windowDurationMins : null };
18
+ }
19
+ function bucketValue(value, id = null) {
20
+ if (!record(value)) return null;
21
+ const primary = windowValue(value.primary), secondary = windowValue(value.secondary);
22
+ const credits = record(value.credits) && typeof value.credits.hasCredits === 'boolean' && typeof value.credits.unlimited === 'boolean'
23
+ ? { hasCredits: value.credits.hasCredits, unlimited: value.credits.unlimited } : null;
24
+ if (!primary && !secondary && !credits && !REASONS.has(value.rateLimitReachedType)) return null;
25
+ return { id: bucketId(id) || bucketId(value.limitId), primary, secondary, credits,
26
+ planType: PLANS.has(value.planType) ? value.planType : null,
27
+ reached: REASONS.has(value.rateLimitReachedType) ? value.rateLimitReachedType : null };
28
+ }
29
+ function unavailableReason(error) {
30
+ return error?.code === -32601 || (error?.extra?.rpcCode ?? error?.extra?.rpc_code) === -32601 ? 'unsupported' : 'unavailable';
31
+ }
32
+ function accountValue(value) {
33
+ if (!record(value) || typeof value.requiresOpenaiAuth !== 'boolean' ||
34
+ (value.account != null && (!record(value.account) || !TYPES.has(value.account.type)))) return null;
35
+ const type = value.account?.type || null;
36
+ return { requiresOpenaiAuth: value.requiresOpenaiAuth, type,
37
+ planType: type === 'chatgpt' && PLANS.has(value.account.planType) ? value.account.planType : null,
38
+ authentication: value.requiresOpenaiAuth === false ? 'providerManaged' : type ? 'authenticated' : 'required' };
39
+ }
40
+
41
+ export function createCodexAccountState({ request, onChange = () => {}, now = Date.now } = {}) {
42
+ let state = { status: 'unknown', stale: false, reason: null, type: null, planType: null,
43
+ authentication: 'unknown', requiresOpenaiAuth: null, checkedAt: null,
44
+ rateLimits: { status: 'unknown', stale: false, reason: null, checkedAt: null, buckets: [] } };
45
+ let accountRevision = 0, limitsRevision = 0, inFlight = null, closed = false, recheck = false;
46
+ const snapshot = () => structuredClone(state);
47
+ const publish = () => { try { onChange(snapshot()); } catch {} };
48
+ function relevantLimits() { return state.status === 'ready' && state.type === 'chatgpt' && state.requiresOpenaiAuth === true; }
49
+ function notification(method, params = {}) {
50
+ if (!['account/updated', 'account/rateLimits/updated', 'account/login/completed'].includes(method)) return false;
51
+ if (closed) return true;
52
+ if (method !== 'account/rateLimits/updated') {
53
+ // Account changes can precede a read reply. That older response cannot
54
+ // reattach the previous account's quotas to the current executor.
55
+ accountRevision++; limitsRevision++; recheck = true;
56
+ state = { ...state, status: 'unknown', stale: true, reason: null, type: null, planType: null,
57
+ authentication: 'unknown', requiresOpenaiAuth: null,
58
+ rateLimits: { status: 'unknown', stale: false, reason: null, checkedAt: null, buckets: [] } };
59
+ publish();
60
+ queueMicrotask(() => { if (!closed && !inFlight) void refresh(); });
61
+ return true;
62
+ }
63
+ if (!relevantLimits()) return true;
64
+ const next = bucketValue(params.rateLimits);
65
+ if (!next) return true;
66
+ limitsRevision++;
67
+ const buckets = state.rateLimits.buckets.slice();
68
+ if (next.id === null && buckets.length === 1) next.id = buckets[0].id;
69
+ if (next.id === null && buckets.length > 1 && !buckets.some(entry => entry.id === null)) return true;
70
+ const index = buckets.findIndex(entry => entry.id === next.id);
71
+ if (index < 0) { if (buckets.length < 16) buckets.push(next); }
72
+ else {
73
+ const previous = buckets[index];
74
+ // Rolling updates are sparse. Null metadata/windows mean unavailable in
75
+ // this update, not a quota of zero or a request to erase the prior value.
76
+ buckets[index] = Object.fromEntries(Object.keys(next).map(key => [key,
77
+ ['primary', 'secondary'].includes(key) && next[key] && previous[key]
78
+ ? Object.fromEntries(Object.keys(next[key]).map(field => [field, next[key][field] ?? previous[key][field]]))
79
+ : next[key] ?? previous[key]]));
80
+ }
81
+ state.rateLimits = { status: 'ready', stale: false, reason: null, checkedAt: now(), buckets };
82
+ publish(); return true;
83
+ }
84
+ async function read() {
85
+ const revision = accountRevision;
86
+ recheck = false;
87
+ let value;
88
+ try { value = accountValue(await request('account/read', { refreshToken: false }));
89
+ if (!value) throw Object.assign(new Error('Invalid account response'), { code: 'CODEX_ACCOUNT_PROTOCOL_ERROR' });
90
+ } catch (error) {
91
+ if (!closed && revision === accountRevision) {
92
+ state.status = 'unavailable'; state.stale = state.checkedAt !== null; state.reason = unavailableReason(error);
93
+ state.authentication = 'unknown';
94
+ state.rateLimits = { ...state.rateLimits, status: 'unavailable', stale: state.rateLimits.checkedAt !== null, reason: 'unavailable' };
95
+ publish();
96
+ }
97
+ return snapshot();
98
+ }
99
+ if (closed || revision !== accountRevision) return snapshot();
100
+ state = { ...state, ...value, status: 'ready', stale: false, reason: null, checkedAt: now() };
101
+ if (!relevantLimits()) {
102
+ state.rateLimits = { status: 'notApplicable', stale: false, reason: null, checkedAt: null, buckets: [] };
103
+ publish(); return snapshot();
104
+ }
105
+ publish();
106
+ const quotaRevision = limitsRevision;
107
+ try {
108
+ const response = await request('account/rateLimits/read', {});
109
+ if (!record(response) || !record(response.rateLimits)) throw new Error('Invalid quota response');
110
+ const entries = record(response.rateLimitsByLimitId) ? Object.entries(response.rateLimitsByLimitId)
111
+ .filter(([id]) => bucketId(id)).slice(0, 16).map(([id, bucket]) => bucketValue(bucket, id)).filter(Boolean) : [];
112
+ const legacy = bucketValue(response.rateLimits);
113
+ const buckets = entries.length ? entries : legacy ? [legacy] : [];
114
+ if (!closed && revision === accountRevision && quotaRevision === limitsRevision) {
115
+ state.rateLimits = { status: buckets.length ? 'ready' : 'unavailable', stale: false,
116
+ reason: buckets.length ? null : 'unavailable', checkedAt: buckets.length ? now() : null, buckets };
117
+ publish();
118
+ }
119
+ } catch (error) {
120
+ if (!closed && revision === accountRevision && quotaRevision === limitsRevision) {
121
+ state.rateLimits = { ...state.rateLimits, status: 'unavailable', stale: state.rateLimits.checkedAt !== null, reason: unavailableReason(error) };
122
+ publish();
123
+ }
124
+ }
125
+ return snapshot();
126
+ }
127
+ function refresh() {
128
+ if (closed) return Promise.resolve(snapshot());
129
+ if (inFlight) return inFlight;
130
+ inFlight = read().finally(() => {
131
+ inFlight = null;
132
+ if (recheck && !closed) queueMicrotask(() => { if (!inFlight && !closed) void refresh(); });
133
+ });
134
+ return inFlight;
135
+ }
136
+ function close() {
137
+ closed = true; accountRevision++; limitsRevision++;
138
+ state.status = 'unavailable'; state.stale = state.checkedAt !== null; state.reason = 'disconnected';
139
+ state.authentication = 'unknown';
140
+ state.rateLimits = { ...state.rateLimits, status: 'unavailable', stale: state.rateLimits.checkedAt !== null, reason: 'disconnected' };
141
+ }
142
+ return { snapshot, refresh, notification, close };
143
+ }
@@ -1,3 +1,7 @@
1
+ import { createMcpFormValidator } from './mcp-elicitation.mjs';
2
+ import { createMcpUrlValidator } from './mcp-url-elicitation.mjs';
3
+
4
+ const mcpForms = createMcpFormValidator(), mcpUrls = createMcpUrlValidator();
1
5
  // Shared validation for the official App Server command/file/permissions/input
2
6
  // server requests. Human responses may narrow a request, never widen it.
3
7
  export const CODEX_INTERACTION_METHODS = new Set([
@@ -37,20 +41,18 @@ function subset(selected, requested) {
37
41
  fail('Granted permissions must be an exact subset of the requested permissions');
38
42
  }
39
43
  }
40
- // Codex 0.144.6 hosts MCP tool approvals as an empty form elicitation.
41
- // Return the official action/content shape. Do not persist permission in _meta,
42
- // and do not pretend arbitrary form fields or URL authentication were answered.
44
+ // Reply only to the correlated request. URL acceptance has no form content or
45
+ // authentication result; the MCP server owns completion of the external flow.
43
46
  function mcpResponse(request, input) {
44
- if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('An MCP approval decision is required');
47
+ if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('An MCP decision is required');
45
48
  if (input.decision !== 'accept') return { action: input.decision };
46
- const params = request.params, schema = params?.requestedSchema;
47
- if (params?.mode !== 'form' || params?._meta?.codex_approval_kind !== 'mcp_tool_call' ||
48
- !object(schema) || schema.type !== 'object' || !object(schema.properties) ||
49
- Object.keys(schema.properties).length || (schema.required?.length || 0) ||
50
- Object.keys(schema).some(key => !['type', 'properties', 'required', 'additionalProperties'].includes(key))) {
51
- fail('Only an explicit empty-form MCP tool approval is supported; decline or cancel other elicitations');
49
+ if (request.params?.mode === 'url') {
50
+ mcpUrls.describe(request.params);
51
+ if (input.content !== undefined || input.answers !== undefined || input.permissions !== undefined) fail('MCP URL responses cannot contain form content or permissions');
52
+ return { action: 'accept' };
52
53
  }
53
- return { action: 'accept', content: {} };
54
+ const { fields } = mcpForms.describe(request.params);
55
+ return { action: 'accept', content: mcpForms.validate(request.params, input.content === undefined && !fields.length ? {} : input.content) };
54
56
  }
55
57
 
56
58
  function permissionsResponse(request, input) {
@@ -152,18 +152,12 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
152
152
  dispose() {
153
153
  if (disposed) return;
154
154
  retiring = true;
155
- try {
156
- owned(db => {
157
- peers.touchPeer(db, peer, 'exited');
158
- db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ? AND runtime_target = ?').run(now(), peer, owner);
159
- addEvent(db, 'dsh.cordis.closed', peer, null, { session_id: sessionId });
160
- }, true, true);
161
- disposed = true;
162
- } catch (error) {
163
- // A lost generation cannot be retried; storage failures can be.
164
- if (error?.code === 'DSH_COLLABORATION_CONFLICT') disposed = true;
165
- throw error;
166
- }
155
+ owned(db => {
156
+ peers.touchPeer(db, peer, 'exited');
157
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ? AND runtime_target = ?').run(now(), peer, owner);
158
+ addEvent(db, 'dsh.cordis.closed', peer, null, { session_id: sessionId });
159
+ }, true, true);
160
+ disposed = true;
167
161
  }
168
162
  };
169
163
  }
@@ -10,8 +10,20 @@ export const name = 'hello-cc-dsh-collaboration';
10
10
  export const inject = ['agents', 'tools', 'sessions'];
11
11
  export const DSH_CORDIS_VERSION = '0.2.0-rc.2';
12
12
 
13
- const failedRetirements = new Map();
14
- const disposedAgents = new WeakSet();
13
+ // A failed retirement is retried only after exact Agent disposal, or by that
14
+ // same Agent after its previous plugin context has been disposed.
15
+ const retirementRegistryKey = Symbol.for('@logicseek/hello-cc/dsh-cordis/retirements/v1');
16
+ if (!Object.hasOwn(globalThis, retirementRegistryKey)) {
17
+ Object.defineProperty(globalThis, retirementRegistryKey, {
18
+ value: Object.freeze({ failedRetirements: new Map(), disposedAgents: new WeakSet() })
19
+ });
20
+ }
21
+ const retirementRegistry = globalThis[retirementRegistryKey];
22
+ if (!(retirementRegistry?.failedRetirements instanceof Map) ||
23
+ !(retirementRegistry?.disposedAgents instanceof WeakSet)) {
24
+ throw new Error('Invalid hello-cc Cordis retirement registry');
25
+ }
26
+ const { failedRetirements, disposedAgents } = retirementRegistry;
15
27
 
16
28
  function retire(agent, state, { agentDisposed = false, contextDisposed = false }, forget) {
17
29
  let entry = failedRetirements.get(agent);
@@ -22,17 +34,9 @@ function retire(agent, state, { agentDisposed = false, contextDisposed = false }
22
34
  if (agentDisposed) entry.agentDisposed = true;
23
35
  if (contextDisposed) entry.contextDisposed = true;
24
36
  }
25
- try {
26
- entry.state.dispose();
27
- entry.forget();
28
- failedRetirements.delete(agent);
29
- } catch (error) {
30
- if (error?.code === 'DSH_COLLABORATION_CONFLICT') {
31
- entry.forget();
32
- failedRetirements.delete(agent);
33
- }
34
- throw error;
35
- }
37
+ entry.state.dispose();
38
+ entry.forget();
39
+ failedRetirements.delete(agent);
36
40
  }
37
41
 
38
42
  function retryRetirements(candidate) {
@@ -2,12 +2,14 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import process from 'node:process';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
- import { spawn } from 'node:child_process';
5
+ import { spawnPinnedCwdProcess } from '../process/pinned-cwd.mjs';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { CliError } from '../shared/errors.mjs';
8
8
  import { withFileLock } from '../shared/file-lock.mjs';
9
9
  import { commandPath } from '../cli-runtime.mjs';
10
10
  import { shellQuoteArg } from '../format.mjs';
11
+ import { resolveProjectDatabase, ensurePrivateProjectStateSubdirectory } from '../runtime/project-path.mjs';
12
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
11
13
 
12
14
  export const DSH_BASELINE_VERSION = '0.2.0-rc.2';
13
15
  export const DSH_INSTALL_HINT = `npm install -g @deepseek-ai/dsh@${DSH_BASELINE_VERSION}`;
@@ -42,16 +44,14 @@ function integrationPaths(ctx, create = false) {
42
44
  try { root = fs.realpathSync(ctx.root); }
43
45
  catch { throw new CliError('BAD_ARGS', `Project root does not exist: ${ctx.root}`); }
44
46
  if (!fs.statSync(root).isDirectory()) throw new CliError('BAD_ARGS', `Project root is not a directory: ${root}`);
45
- const stateDir = path.join(root, '.hello-cc');
47
+ const stateDir = resolveProjectDatabase({ root, createStateDir: create }).stateDir;
46
48
  const directory = path.join(stateDir, 'dsh');
49
+ // DSH artifacts are opened and renamed by pathname. Their parent must be
50
+ // owned and non-rebindable by another OS user before either operation.
51
+ ensurePrivateProjectStateSubdirectory(root, 'dsh', { create });
47
52
  const parents = [];
48
53
  for (const dir of [root, stateDir, directory]) {
49
- let stat = lstat(dir);
50
- if (!stat && create) {
51
- try { fs.mkdirSync(dir, { mode: 0o700 }); }
52
- catch (error) { if (error.code !== 'EEXIST') throw error; }
53
- stat = lstat(dir);
54
- }
54
+ const stat = lstat(dir);
55
55
  if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
56
56
  throw forbidden(dir, 'expected an ordinary directory, found a symlink or other object');
57
57
  }
@@ -75,8 +75,11 @@ function readArtifact(paths, name) {
75
75
  const file = path.join(paths.directory, name);
76
76
  const stat = lstat(file);
77
77
  if (!stat) return null;
78
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
79
- throw forbidden(file, 'expected an ordinary file with no symlink or hard-link alias');
78
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
79
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
80
+ (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) ||
81
+ unsafeDirectoryAcl(file, stat)) {
82
+ throw forbidden(file, 'expected an owned private file with no symlink or hard-link alias');
80
83
  }
81
84
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
82
85
  try {
@@ -268,7 +271,9 @@ export async function launchDshWeb(ctx, options = {}) {
268
271
  const env = createDshEnvironment(options.env || process.env, { dshHome: options.dshHome, cwd: ctx.cwd });
269
272
  const args = ['web', '--patch', setup.patchPath, ...(options.args || [])];
270
273
  return new Promise((resolve, reject) => {
271
- const child = spawn(binary, args, { cwd: setup.root, env, stdio: options.stdio || 'inherit' });
274
+ const child = spawnPinnedCwdProcess(binary, args, {
275
+ cwd: setup.root, env, stdio: options.stdio || 'inherit'
276
+ });
272
277
  const handlers = new Map();
273
278
  const cleanup = () => {
274
279
  for (const signal of handlers.keys()) process.removeListener(signal, handlers.get(signal));
@@ -0,0 +1,116 @@
1
+ // Bounded MCP 2025-11-25 form schemas. This factory is also embedded in the Web
2
+ // responder so the UI and owning executor apply the same constraints.
3
+ export function createMcpFormValidator() {
4
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const own = (value, key) => Object.hasOwn(value, key);
6
+ function fail(message) { throw Object.assign(new Error(message), { code: 'INTERACTION_RESPONSE_INVALID' }); }
7
+ function shape(value, allowed) {
8
+ if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('Unsupported MCP form schema');
9
+ }
10
+ function text(value) { return typeof value === 'string' && value.length <= 8192; }
11
+ function bound(value) { return Number.isSafeInteger(value) && value >= 0; }
12
+ function options(schema, titledKey, legacy = false) {
13
+ let values;
14
+ if (own(schema, 'enum')) {
15
+ if (!Array.isArray(schema.enum) || schema.enum.some(value => !text(value))) fail('Invalid MCP form options');
16
+ if (legacy && own(schema, 'enumNames') && (!Array.isArray(schema.enumNames) || schema.enumNames.length !== schema.enum.length || schema.enumNames.some(value => !text(value)))) fail('Invalid MCP option labels');
17
+ values = schema.enum.map((value, i) => ({ value, label: schema.enumNames?.[i] ?? value }));
18
+ } else {
19
+ if (!Array.isArray(schema[titledKey])) fail('Invalid MCP form options');
20
+ values = schema[titledKey].map(entry => {
21
+ shape(entry, ['const', 'title']);
22
+ if (!text(entry.const) || !text(entry.title)) fail('Invalid MCP form options');
23
+ return { value: entry.const, label: entry.title };
24
+ });
25
+ }
26
+ if (!values.length || values.length > 100 || new Set(values.map(entry => entry.value)).size !== values.length) fail('Invalid or excessive MCP form options');
27
+ return values;
28
+ }
29
+ function date(value) {
30
+ return (/^\d{4}-\d{2}-\d{2}$/).test(value) && Number.isFinite(Date.parse(value + 'T00:00:00Z')) && new Date(value + 'T00:00:00Z').toISOString().slice(0, 10) === value;
31
+ }
32
+ function formatted(value, format) {
33
+ if (format === 'email') {
34
+ const at = value.lastIndexOf('@'), local = value.slice(0, at), domain = value.slice(at + 1);
35
+ // Common dot-atom/quoted mailbox syntax and DNS labels; no network lookup.
36
+ const mailbox = (/^(?:[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[A-Za-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[^"\\\r\n]|\\[^\r\n])*")$/).test(local);
37
+ const host = (/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*$/).test(domain);
38
+ return at > 0 && mailbox && host && domain.split('.').every(label => label.length <= 63);
39
+ }
40
+ if (format === 'uri') {
41
+ if (/[^\x21-\x7e]|["<>`{}|\\^]|%(?![\da-fA-F]{2})/.test(value)) return false;
42
+ try { return Boolean(new URL(value).protocol); } catch { return false; }
43
+ }
44
+ if (format === 'date') return date(value);
45
+ if (format === 'date-time') {
46
+ const match = /^(\d{4}-\d{2}-\d{2})[tT](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[zZ]|[+-](\d{2}):(\d{2}))$/.exec(value);
47
+ return Boolean(match && date(match[1]) && Number(match[2]) < 24 && Number(match[3]) < 60 && Number(match[4]) <= 60 && (!match[5] || (Number(match[5]) < 24 && Number(match[6]) < 60)));
48
+ }
49
+ return true;
50
+ }
51
+ function validateValue(field, value) {
52
+ const error = () => fail('Invalid value for MCP field: ' + field.key);
53
+ if (field.kind === 'multi') {
54
+ if (!Array.isArray(value) || value.length > 100 || value.length < (field.minItems ?? 0) || value.length > (field.maxItems ?? 100) || new Set(value).size !== value.length || value.some(entry => !field.options.some(option => option.value === entry))) error();
55
+ } else if (field.kind === 'enum') {
56
+ if (!field.options.some(option => option.value === value)) error();
57
+ } else if (field.type === 'boolean') {
58
+ if (typeof value !== 'boolean') error();
59
+ } else if (field.type === 'number' || field.type === 'integer') {
60
+ if (typeof value !== 'number' || !Number.isFinite(value) || (field.type === 'integer' && !Number.isSafeInteger(value)) || (field.minimum != null && value < field.minimum) || (field.maximum != null && value > field.maximum)) error();
61
+ } else {
62
+ if (!text(value) || Array.from(value).length < (field.minLength ?? 0) || Array.from(value).length > (field.maxLength ?? 8192) || !formatted(value, field.format)) error();
63
+ }
64
+ }
65
+ function describe(params) {
66
+ if (params?.mode !== 'form') fail('This MCP request mode cannot be answered in Web; decline or cancel');
67
+ const schema = params.requestedSchema;
68
+ shape(schema, ['$schema', 'type', 'properties', 'required', 'additionalProperties']);
69
+ if (schema.type !== 'object' || !object(schema.properties) || (own(schema, '$schema') && !text(schema.$schema)) || (own(schema, 'additionalProperties') && typeof schema.additionalProperties !== 'boolean') || JSON.stringify(schema).length > 65536) fail('Unsupported MCP form schema');
70
+ const entries = Object.entries(schema.properties), required = own(schema, 'required') ? schema.required : [];
71
+ if (entries.length > 50 || !Array.isArray(required) || new Set(required).size !== required.length || required.some(key => typeof key !== 'string' || !own(schema.properties, key))) fail('Invalid or excessive MCP form fields');
72
+ const fields = entries.map(([key, definition]) => {
73
+ if (!key || key.length > 512 || !object(definition)) fail('Invalid MCP field identity');
74
+ const type = definition.type;
75
+ let allowed, kind = type;
76
+ if (type === 'string') {
77
+ kind = own(definition, 'enum') || own(definition, 'oneOf') ? 'enum' : 'string';
78
+ allowed = kind === 'enum' ? (own(definition, 'enum') ? ['enum', 'enumNames'] : ['oneOf']) : ['minLength', 'maxLength', 'format'];
79
+ } else if (type === 'array') { kind = 'multi'; allowed = ['items', 'minItems', 'maxItems']; }
80
+ else if (type === 'boolean') allowed = [];
81
+ else if (type === 'number' || type === 'integer') allowed = ['minimum', 'maximum'];
82
+ else fail('Nested objects or unsupported MCP field types cannot be answered in Web');
83
+ shape(definition, ['type', 'title', 'description', 'default', ...allowed]);
84
+ for (const property of ['title', 'description']) if (own(definition, property) && !text(definition[property])) fail('Invalid MCP field label');
85
+ const field = { ...definition, key, kind, required: required.includes(key) };
86
+ for (const property of ['minLength', 'maxLength', 'minItems', 'maxItems']) if (own(definition, property) && !bound(definition[property])) fail('Invalid MCP field bounds');
87
+ for (const property of ['minimum', 'maximum']) if (own(definition, property) && (typeof definition[property] !== 'number' || !Number.isFinite(definition[property]))) fail('Invalid MCP numeric bounds');
88
+ if ((definition.minLength ?? 0) > Math.min(definition.maxLength ?? 8192, 8192) || (definition.minimum != null && definition.maximum != null && definition.minimum > definition.maximum) || (own(definition, 'format') && !['email', 'uri', 'date', 'date-time'].includes(definition.format))) fail('Unsupported or unsatisfiable MCP field constraints');
89
+ if (kind === 'enum') field.options = options(definition, 'oneOf', true);
90
+ if (kind === 'multi') {
91
+ const items = definition.items;
92
+ if (own(items || {}, 'enum')) { shape(items, ['type', 'enum']); if (items.type !== 'string') fail('Unsupported MCP array items'); }
93
+ else shape(items, [own(items || {}, 'anyOf') ? 'anyOf' : 'oneOf']);
94
+ field.options = options(items, own(items, 'anyOf') ? 'anyOf' : 'oneOf');
95
+ if ((definition.minItems ?? 0) > Math.min(definition.maxItems ?? 100, field.options.length)) fail('Unsatisfiable MCP selection constraints');
96
+ }
97
+ if (own(definition, 'default')) validateValue(field, definition.default);
98
+ return field;
99
+ });
100
+ return { fields };
101
+ }
102
+ function validate(params, content) {
103
+ const { fields } = describe(params);
104
+ if (!object(content) || Object.keys(content).some(key => !fields.some(field => field.key === key))) fail('MCP form content must contain only requested fields');
105
+ const result = {};
106
+ for (const field of fields) {
107
+ if (!own(content, field.key)) { if (field.required) fail('Required MCP field is missing: ' + field.key); continue; }
108
+ validateValue(field, content[field.key]);
109
+ // Field names are untrusted. Define own properties without invoking setters.
110
+ Object.defineProperty(result, field.key, { value: Array.isArray(content[field.key]) ? [...content[field.key]] : content[field.key], enumerable: true, configurable: true, writable: true });
111
+ }
112
+ if (JSON.stringify(result).length > 65536) fail('MCP form content exceeds the response limit');
113
+ return result;
114
+ }
115
+ return { describe, validate };
116
+ }
@@ -0,0 +1,25 @@
1
+ // The same bounded URL contract runs in the responder and browser. Acceptance
2
+ // acknowledges an out-of-band flow; it never proves authentication succeeded.
3
+ export function createMcpUrlValidator() {
4
+ function fail() {
5
+ throw Object.assign(new Error('MCP authorization needs a valid HTTPS URL (or loopback HTTP) and elicitation identity'), { code: 'INTERACTION_RESPONSE_INVALID' });
6
+ }
7
+ function describe(params) {
8
+ if (!params || params.mode !== 'url' || typeof params.elicitationId !== 'string' ||
9
+ !params.elicitationId.trim() || params.elicitationId.length > 512 || /[\u0000-\u001f\u007f]/.test(params.elicitationId) ||
10
+ typeof params.url !== 'string' || !params.url || params.url.length > 8192 || /[\\\s\u0000-\u001f\u007f]/.test(params.url)) fail();
11
+ let url;
12
+ try { url = new URL(params.url); } catch { fail(); }
13
+ if (url.username || url.password || !url.hostname || !['http:', 'https:'].includes(url.protocol) ||
14
+ (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) fail();
15
+ return { href: url.href, origin: url.origin, loopback: ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) };
16
+ }
17
+ return { describe };
18
+ }
19
+
20
+ // Pending request parameters exist in live memory only. Event histories must
21
+ // not retain authorization URLs, device codes, opaque ids, messages or metadata.
22
+ export function redactMcpUrlInteraction(request) {
23
+ if (request?.method !== 'mcpServer/elicitation/request' || request.params?.mode !== 'url') return request;
24
+ return { ...request, params: { mode: 'url', serverName: request.params.serverName } };
25
+ }
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { createNativeInteractions } from './interactions.mjs';
6
6
  import { CliError } from '../../shared/errors.mjs';
7
+ import { claudeUsage } from './telemetry.mjs';
8
+ import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
7
9
 
8
10
  const SESSION_MARKERS = new Set([
9
11
  'CLAUDECODE', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_PARENT_SESSION_ID',
@@ -84,6 +86,7 @@ function withTimeout(promise, timeoutMs, operation) {
84
86
 
85
87
  // This adapter owns an SDK subprocess. It does not attach to a running TUI.
86
88
  export function createClaudeAdapter(options = {}) {
89
+ const cwdBinding = options.cwd ? captureSelectedCwdIdentity(options.cwd, options.expectedCwdIdentity) : null;
87
90
  const capabilities = Object.freeze({
88
91
  create: true, resume: true, send: true, observe: true,
89
92
  interrupt: true, close: true, steer: false, fork: false, approvals: Boolean(options.interactive), mcp: Boolean(options.mcpServers)
@@ -94,6 +97,7 @@ export function createClaudeAdapter(options = {}) {
94
97
  let sessionId = null;
95
98
  let requestedSessionId = null;
96
99
  let model;
100
+ let metrics = null, runtimeMetadata = {};
97
101
  let queryFunction = options.query;
98
102
  let sdkQuery = null;
99
103
  let pump = null;
@@ -113,6 +117,7 @@ export function createClaudeAdapter(options = {}) {
113
117
  provider: 'claude', status, sessionId,
114
118
  turnId: activeTurn?.id || null,
115
119
  submissionId: activeTurn?.submissionId || null,
120
+ metrics, runtimeMetadata: { ...runtimeMetadata },
116
121
  capabilities, executorId: interactions.executorId, pendingApprovals: interactions.snapshot()
117
122
  };
118
123
  }
@@ -180,6 +185,9 @@ export function createClaudeAdapter(options = {}) {
180
185
  return;
181
186
  }
182
187
  sessionId = initializedId;
188
+ runtimeMetadata = { ...(typeof message.model === 'string' ? { model: message.model } : {}),
189
+ ...(typeof message.permissionMode === 'string' ? { permissionMode: message.permissionMode } : {}) };
190
+ emit('metadata', { runtimeMetadata: { ...runtimeMetadata } });
183
191
  emit('status', { status, initialized: Boolean(sessionId) });
184
192
  return;
185
193
  }
@@ -207,6 +215,10 @@ export function createClaudeAdapter(options = {}) {
207
215
  return;
208
216
  }
209
217
  if (message.type !== 'result') return;
218
+ if (belongs) {
219
+ const reported = claudeUsage(message, turn.id);
220
+ if (reported) { metrics = reported; emit('usage', { metrics }, turn); }
221
+ }
210
222
  if (belongs) interactions.expire(turn.id);
211
223
  if (!belongs) {
212
224
  // Startup errors may precede input consumption and have no UUID echo.
@@ -242,6 +254,7 @@ export function createClaudeAdapter(options = {}) {
242
254
  const resumeId = request.sessionId;
243
255
  const selectedModel = request.model;
244
256
  if (closed) throw new CliError('NATIVE_CLOSED', 'Claude native session is closed');
257
+ cwdBinding?.assertUnchanged();
245
258
  if (opening) return opening;
246
259
  if (status !== 'created') {
247
260
  if ((resumeId && resumeId !== requestedSessionId) ||
@@ -307,6 +320,7 @@ export function createClaudeAdapter(options = {}) {
307
320
  // Assign before invoking the factory so close() can observe and await any
308
321
  // in-progress resource creation, including factories that resolve late.
309
322
  startupPromise = Promise.resolve().then(async () => {
323
+ cwdBinding?.assertUnchanged();
310
324
  sdkQuery = await queryFunction({
311
325
  prompt: inputs,
312
326
  options: {
@@ -372,6 +386,7 @@ export function createClaudeAdapter(options = {}) {
372
386
  'Claude native session is busy; steering is unsupported. Wait for completion or interrupt it.');
373
387
  if (expectedTurnId) throw new CliError('NATIVE_STALE_TURN', 'Claude native session has no active turn to steer');
374
388
  if (queryEnded) throw new CliError('NATIVE_SDK_ENDED', 'Claude SDK stream has ended; open a new adapter to resume');
389
+ cwdBinding?.assertUnchanged();
375
390
  const turn = {
376
391
  id: randomUUID(), inputId: randomUUID(),
377
392
  submissionId: submissionId || randomUUID(), consumed: false,
@@ -384,6 +399,7 @@ export function createClaudeAdapter(options = {}) {
384
399
  if (closed || queryEnded || activeTurn !== turn) {
385
400
  throw new CliError('NATIVE_SDK_ENDED', 'Claude SDK ended before accepting the submission');
386
401
  }
402
+ cwdBinding?.assertUnchanged();
387
403
  inputs.push({
388
404
  type: 'user', uuid: turn.inputId,
389
405
  ...(sessionId ? { session_id: sessionId } : {}),
@@ -431,12 +447,14 @@ export function createClaudeAdapter(options = {}) {
431
447
  // A deadline bounds callers, while the cleanup keeps waiting for a late
432
448
  // factory and tears down its resource even if the caller already timed out.
433
449
  const cleanup = (async () => {
434
- if (opening) await opening.catch(() => {});
435
- if (startupPromise) await startupPromise.catch(() => {});
436
- await teardownQuery();
437
- if (pump) await pump;
438
- setStatus('closed', turn ? { cancelled: true } : {}, turn);
439
- return snapshot();
450
+ try {
451
+ if (opening) await opening.catch(() => {});
452
+ if (startupPromise) await startupPromise.catch(() => {});
453
+ await teardownQuery();
454
+ if (pump) await pump;
455
+ setStatus('closed', turn ? { cancelled: true } : {}, turn);
456
+ return snapshot();
457
+ } finally { cwdBinding?.release(); }
440
458
  })();
441
459
  closePromise = withTimeout(cleanup, timeoutMs, 'close').catch((error) => {
442
460
  // A caller may retry shutdown after the deadline or a teardown failure.