@ai-workbench/remote-agent 0.8.21 → 0.8.23

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 (95) hide show
  1. package/README.md +27 -156
  2. package/dist/acp.d.ts +20 -15
  3. package/dist/acp.js +197 -224
  4. package/dist/agents.d.ts +0 -1
  5. package/dist/agents.js +2 -2
  6. package/dist/broker-runtime.d.ts +3 -5
  7. package/dist/broker-runtime.js +45 -342
  8. package/dist/broker.d.ts +6 -31
  9. package/dist/broker.js +42 -113
  10. package/dist/catalog-ssh.d.ts +2 -31
  11. package/dist/catalog-ssh.js +9 -39
  12. package/dist/claude-local.d.ts +1 -1
  13. package/dist/claude-local.js +40 -105
  14. package/dist/client.js +34 -127
  15. package/dist/close-diagnostics.d.ts +6 -20
  16. package/dist/close-diagnostics.js +13 -57
  17. package/dist/connect-contract.d.ts +4 -8
  18. package/dist/conversation-details.d.ts +0 -29
  19. package/dist/conversation-details.js +8 -99
  20. package/dist/employee-endpoint.d.ts +1 -3
  21. package/dist/employee-endpoint.js +8 -39
  22. package/dist/employee-exec.d.ts +4 -3
  23. package/dist/employee-exec.js +25 -13
  24. package/dist/employee-info.d.ts +5 -1
  25. package/dist/employee-info.js +16 -5
  26. package/dist/employee-location.js +3 -4
  27. package/dist/employee-mcp.js +44 -69
  28. package/dist/employee-native.d.ts +1 -3
  29. package/dist/employee-native.js +6 -27
  30. package/dist/employee-presentation.d.ts +0 -5
  31. package/dist/employee-presentation.js +0 -15
  32. package/dist/employee-provider.d.ts +3 -11
  33. package/dist/employee-provider.js +69 -205
  34. package/dist/employee-reasons.js +1 -5
  35. package/dist/employee-shell.d.ts +2 -1
  36. package/dist/employee-shell.js +10 -13
  37. package/dist/employee-ssh.d.ts +1 -3
  38. package/dist/employee-ssh.js +13 -16
  39. package/dist/employee-tools.d.ts +5 -5
  40. package/dist/failure-evidence.d.ts +2 -10
  41. package/dist/failure-evidence.js +12 -61
  42. package/dist/host-workspace.d.ts +4 -2
  43. package/dist/host-workspace.js +1 -1
  44. package/dist/index.d.ts +13 -43
  45. package/dist/index.js +190 -489
  46. package/dist/input.js +2 -3
  47. package/dist/local-user-turn.js +5 -6
  48. package/dist/mapping.d.ts +0 -3
  49. package/dist/mapping.js +0 -34
  50. package/dist/model-catalog.d.ts +2 -41
  51. package/dist/model-catalog.js +24 -282
  52. package/dist/model-configuration.d.ts +0 -3
  53. package/dist/model-configuration.js +8 -47
  54. package/dist/model-presentation.d.ts +1 -0
  55. package/dist/model-presentation.js +2 -2
  56. package/dist/permissions.d.ts +15 -0
  57. package/dist/permissions.js +30 -0
  58. package/dist/preflight-diagnostic.js +11 -22
  59. package/dist/preflight-payload.d.ts +1 -1
  60. package/dist/preflight-payload.js +2 -2
  61. package/dist/presentation.js +21 -41
  62. package/dist/provider.d.ts +12 -34
  63. package/dist/provider.js +17 -92
  64. package/dist/readiness.js +1 -7
  65. package/dist/remote-details.d.ts +41 -17
  66. package/dist/remote-details.js +84 -381
  67. package/dist/workspace-source.d.ts +1 -25
  68. package/dist/workspace-source.js +1 -138
  69. package/package.json +11 -12
  70. package/dist/catalog-diagnostics.d.ts +0 -16
  71. package/dist/catalog-diagnostics.js +0 -108
  72. package/dist/host-layout.d.ts +0 -8
  73. package/dist/host-layout.js +0 -9
  74. package/dist/job.d.ts +0 -17
  75. package/dist/job.js +0 -57
  76. package/dist/model-defaults.d.ts +0 -29
  77. package/dist/model-defaults.js +0 -62
  78. package/dist/oneshot.d.ts +0 -13
  79. package/dist/oneshot.js +0 -78
  80. package/dist/protocol-boundary.d.ts +0 -13
  81. package/dist/protocol-boundary.js +0 -49
  82. package/dist/provider-locale.d.ts +0 -19
  83. package/dist/provider-locale.js +0 -73
  84. package/dist/recovery-checkpoint.d.ts +0 -6
  85. package/dist/recovery-checkpoint.js +0 -71
  86. package/dist/recovery.d.ts +0 -51
  87. package/dist/recovery.js +0 -61
  88. package/dist/sdk-ssh-wrapper.d.ts +0 -2
  89. package/dist/sdk-ssh-wrapper.js +0 -34
  90. package/dist/stage-timing.d.ts +0 -31
  91. package/dist/stage-timing.js +0 -61
  92. package/dist/workspace-notice.d.ts +0 -47
  93. package/dist/workspace-notice.js +0 -273
  94. package/dist/workspace-source-confirmation.d.ts +0 -4
  95. package/dist/workspace-source-confirmation.js +0 -12
@@ -7,22 +7,22 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { connectNeedError } from "./connect-need.js";
10
- import { CODEX_EMPLOYEE_SCOPE_NOTICE } from "./employee-presentation.js";
11
- import { CloseDiagnostics, withFailureEvidence, withFirstFailureEvidence, closeCode, closeErrorCodes } from "./close-diagnostics.js";
10
+ import { EMPLOYEE_EXECUTION_NOTICE } from "./employee-presentation.js";
11
+ import { CloseDiagnostics, withFirstFailureEvidence, closeCode } from "./close-diagnostics.js";
12
12
  import { createHash } from 'node:crypto';
13
13
  import { admitEmployeeLocation } from "./employee-location.js";
14
14
  import { EmployeeExecution } from "./employee-exec.js";
15
15
  import { createEmployeeSshTransport } from "./employee-ssh.js";
16
16
  import { sshIo } from "./broker.js";
17
17
  import { NeedError } from "./remote.js";
18
- const fail = (code) => new NeedError(code, code === 'EMPLOYEE_OPERATION_UNSUPPORTED' ? '当前本机工作区只支持正在连接的会话;单次运行和断线后的冷恢复暂不支持,请新建会话。' : '本机工作区的执行连接无法继续(' + code + '),本轮没有改用主机执行。', '当前 L 工作区绑定、N/S 员工执行端点与支持本机执行的会话适配器');
18
+ const fail = (code) => new NeedError(code, '本机工作区的执行连接无法继续,本轮没有改用主机执行;恢复连接后可在原会话继续。', '当前 L 工作区绑定、N/S 员工执行端点与支持本机执行的会话适配器');
19
19
  const errorCode = (error) => error instanceof NeedError ? error.code : 'EMPLOYEE_EXEC_UNKNOWN';
20
20
  const asNeed = (error) => error instanceof NeedError ? error : fail('EMPLOYEE_EXEC_UNKNOWN');
21
21
  const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
22
22
  const absolute = (value) => typeof value === 'string' && value.startsWith('/') && !value.includes('\0');
23
- const localApiCurrent = (api) => { const c = api.getContractInfo?.(); return c?.workspaceLocations === 1 && c.localWorkspaceContext === 1 && c.localUserTurnAcceptance === 1 && typeof api.getWorkspaceLocationState === 'function' && typeof api.getLocalWorkspaceContext === 'function' && typeof api.acceptLocalWorkspaceUserTurn === 'function'; };
24
- /** Read-only local classification and explicit acceptance precede even ensureReady's SSH status. */
25
- export async function acceptEmployeeUserTurn(get, employee, agent, api, request) {
23
+ const localApiCurrent = (api) => { const c = api.getContractInfo?.(); return c?.workspaceLocations === 1 && c.localWorkspaceContext === 1 && typeof api.getWorkspaceLocationState === 'function' && typeof api.getLocalWorkspaceContext === 'function'; };
24
+ /** Inspect the current local workspace once at turn start, before host readiness. */
25
+ export async function inspectEmployeeWorkspace(get, employee, agent, api, request) {
26
26
  const info = api.getContractInfo?.();
27
27
  if (info?.workspaceLocations !== 1)
28
28
  return;
@@ -46,7 +46,7 @@ export async function acceptEmployeeUserTurn(get, employee, agent, api, request)
46
46
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
47
47
  if (row.source !== 'local')
48
48
  return;
49
- if (agent.id !== 'codex-remote' || agent.loginProvider !== 'codex' || state.phase !== 'ready' || row.status !== 'selected' || !row.bindingId || info.localWorkspaceContext !== 1 || info.localUserTurnAcceptance !== 1 || typeof api.getLocalWorkspaceContext !== 'function' || typeof api.acceptLocalWorkspaceUserTurn !== 'function' || typeof employee.currentUserTurn !== 'function')
49
+ if (agent.id !== 'codex-remote' || agent.loginProvider !== 'codex' || state.phase !== 'ready' || row.status !== 'selected' || !row.bindingId || info.localWorkspaceContext !== 1 || typeof api.getLocalWorkspaceContext !== 'function')
50
50
  throw fail('DEPENDENCY_REQUIRED');
51
51
  const connection = api.getConnectionState();
52
52
  const stable = () => { sourceStable(); const now = api.getConnectionState(), location = api.getWorkspaceLocationState(), matches = location.items.filter(x => x.workspaceId === workspace.id); if (get() !== api || now.phase !== 'connected' || now.instanceId !== connection.instanceId || now.epoch !== connection.epoch || location.phase !== 'ready' || location.instanceId !== connection.instanceId || location.epoch !== connection.epoch || matches.length !== 1 || matches[0].source !== 'local' || matches[0].status !== 'selected' || matches[0].bindingId !== row.bindingId)
@@ -59,40 +59,11 @@ export async function acceptEmployeeUserTurn(get, employee, agent, api, request)
59
59
  const c = context.value, d = { ...c.directory };
60
60
  if (c.version !== 1 || c.source !== 'local' || c.instanceId !== connection.instanceId || c.epoch !== connection.epoch || c.workspaceId !== workspace.id || c.bindingId !== row.bindingId || !absolute(d?.path) || d.path !== workspace.path || ![d.device, d.inode].every(n => Number.isSafeInteger(n) && n >= 0))
61
61
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
62
- const expected = { ...employee.currentUserTurn(request) };
63
- if (!Number.isSafeInteger(expected.turn) || expected.turn < 0 || typeof expected.userMessageId !== 'string' || !expected.userMessageId.trim())
64
- throw fail('LOCAL_USER_TURN_UNCONFIRMED');
65
- const response = await api.acceptLocalWorkspaceUserTurn({ instanceId: connection.instanceId, epoch: connection.epoch, sessionId: request.sessionId, provider: agent.loginProvider, purpose: 'user-turn', workspaceId: workspace.id, expectedBindingId: row.bindingId, expectedDirectory: { device: d.device, inode: d.inode }, expectedUserTurn: { ...expected } });
66
- if (!response.ok)
67
- throw connectNeedError(response.error);
68
- stable();
69
- const a = response.value;
70
- if (!a || a.version !== 1 || typeof a.acceptanceId !== 'string' || !a.acceptanceId.trim() || a.instanceId !== connection.instanceId || a.epoch !== connection.epoch || a.sessionId !== request.sessionId || a.provider !== agent.loginProvider || a.workspaceId !== workspace.id || a.bindingId !== row.bindingId || a.source !== 'local' || a.directory?.path !== d.path || a.directory?.device !== d.device || a.directory?.inode !== d.inode || a.userTurn?.turn !== expected.turn || a.userTurn?.userMessageId !== expected.userMessageId || ![a.userTurn?.startSequence, a.userTurn?.userMessageSequence].every(n => Number.isSafeInteger(n) && n >= 0) || a.userTurn.userMessageSequence <= a.userTurn.startSequence)
71
- throw fail('LOCAL_USER_TURN_ACCEPTANCE_INVALID');
72
- const accepted = Object.freeze({ ...a, directory: Object.freeze({ ...a.directory }), userTurn: Object.freeze({ ...a.userTurn }) });
73
- request.localAdmissionCheck = () => { sourceStable(); const c = api.getConnectionState(); if (c.phase !== 'connected' || c.instanceId !== accepted.instanceId || c.epoch !== accepted.epoch)
74
- throw fail('EMPLOYEE_CONTEXT_CHANGED'); const turn = employee.currentUserTurn(request); if (turn.turn !== accepted.userTurn.turn || turn.userMessageId !== accepted.userTurn.userMessageId)
75
- throw new NeedError('LOCAL_USER_TURN_CHANGED', '本次输入已变化,本轮已停止。', '当前官方真实员工输入'); };
76
- return accepted;
77
- }
78
- /** Recheck the admitted read-only facts after readiness awaits, before the catalog command. */
79
- export async function validateEmployeeUserTurn(get, employee, api, request, connection) {
80
- const a = request.localAcceptance;
81
- if (!a)
82
- return;
83
- const stable = () => { if (get() !== api || !localApiCurrent(api))
84
- throw fail('EMPLOYEE_CONTEXT_CHANGED'); const c = api.getConnectionState(), state = api.getWorkspaceLocationState(), rows = state.items.filter(x => x.workspaceId === a.workspaceId); if (c.phase !== 'connected' || c.instanceId !== a.instanceId || c.epoch !== a.epoch || connection.instanceId !== a.instanceId || connection.epoch !== a.epoch || state.phase !== 'ready' || state.instanceId !== a.instanceId || state.epoch !== a.epoch || rows.length !== 1 || rows[0].source !== 'local' || rows[0].status !== 'selected' || rows[0].bindingId !== a.bindingId)
85
- throw fail('EMPLOYEE_CONTEXT_CHANGED'); request.signal?.throwIfAborted(); };
86
- stable();
87
- const c = await api.getLocalWorkspaceContext({ instanceId: a.instanceId, epoch: a.epoch, workspaceId: a.workspaceId, expectedBindingId: a.bindingId });
88
- stable();
89
- if (!c.ok)
90
- throw connectNeedError(c.error);
91
- if (c.value.version !== 1 || c.value.source !== 'local' || c.value.instanceId !== a.instanceId || c.value.epoch !== a.epoch || c.value.workspaceId !== a.workspaceId || c.value.bindingId !== a.bindingId || c.value.directory.path !== a.directory.path || c.value.directory.device !== a.directory.device || c.value.directory.inode !== a.directory.inode)
92
- throw fail('EMPLOYEE_CONTEXT_CHANGED');
93
- const turn = employee.currentUserTurn(request);
94
- if (turn.turn !== a.userTurn.turn || turn.userMessageId !== a.userTurn.userMessageId)
95
- throw new NeedError('LOCAL_USER_TURN_CHANGED', '本次输入已变化,本轮已停止。', '当前官方真实员工输入');
62
+ // L0.7.27 provides a read-only context, not a per-turn context ledger.
63
+ const contextSnapshot = Object.freeze({ ...c, directory: Object.freeze(d) });
64
+ request.localAdmissionCheck = () => { sourceStable(); const now = api.getConnectionState(); if (now.phase !== 'connected' || now.instanceId !== c.instanceId || now.epoch !== c.epoch)
65
+ throw fail('EMPLOYEE_CONTEXT_CHANGED'); };
66
+ return contextSnapshot;
96
67
  }
97
68
  /** Source admission is separate from L's legacy host project resolver. */
98
69
  export function createEmployeeProvider(get, options, agent) {
@@ -100,15 +71,11 @@ export function createEmployeeProvider(get, options, agent) {
100
71
  return async (input) => {
101
72
  const { api, target, connection, request } = input, info = api.getContractInfo?.(), sessionId = request.sessionId;
102
73
  let prior = live.get(sessionId);
103
- // A failed close remains a session tombstone. Source/endpoint changes cannot authorize a new host.
104
- // Wait for an in-progress close before re-admission; its rejected result is intentionally sticky.
105
- if (prior?.ended) {
106
- await prior.closing;
107
- prior = live.get(sessionId);
108
- }
109
- if (prior && (prior.current.api !== api || info?.workspaceLocations !== 1 || info?.localWorkspaceContext !== 1)) {
110
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
111
- throw fail('EMPLOYEE_CONTEXT_CHANGED');
74
+ // Each round owns a fresh employee channel. Old cleanup never admits or blocks it.
75
+ if (prior) {
76
+ live.delete(sessionId);
77
+ void prior.dispose(undefined, { layer: 'runtime', initiator: 'provider.next-round', reason: 'NEXT_ROUND', action: 'dispose' }).catch(() => console.warn('EMPLOYEE_CLEANUP_FAILED'));
78
+ prior = undefined;
112
79
  }
113
80
  if (info?.workspaceLocations !== 1 && info?.localWorkspaceContext !== 1)
114
81
  return;
@@ -116,50 +83,56 @@ export function createEmployeeProvider(get, options, agent) {
116
83
  throw fail('DEPENDENCY_REQUIRED');
117
84
  const employee = options.employee;
118
85
  const workspace = await employee.workspace(request);
119
- await input.stable(request.signal);
120
86
  if (!workspace.id || !absolute(workspace.path))
121
87
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
122
88
  const state = api.getWorkspaceLocationState(), rows = state.items.filter(row => row.workspaceId === workspace.id), row = rows[0];
123
- if (state.phase !== 'ready' || state.instanceId !== connection.instanceId || state.epoch !== connection.epoch || rows.length !== 1 || row?.status !== 'selected' || !row.bindingId || !['local', 'host', 'legacy-host'].includes(row.source ?? ''))
89
+ if (state.phase !== 'ready' || state.instanceId !== connection.instanceId || state.epoch !== connection.epoch || rows.length !== 1 || row?.status !== 'selected' || !row.bindingId || !['local', 'host'].includes(row.source ?? ''))
124
90
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
125
91
  if (row.source !== 'local') {
126
- if (prior)
127
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
128
- if (request.localAcceptance)
92
+ if (request.localContext)
129
93
  throw fail('EMPLOYEE_CONTEXT_CHANGED');
130
94
  return;
131
95
  }
132
96
  let details, closeDiagnostics;
97
+ let entry, candidate, opening;
98
+ const disposers = [], lifetime = new AbortController();
99
+ const roundSignal = AbortSignal.any([lifetime.signal, ...request.signal ? [request.signal] : []]);
133
100
  try {
134
- const acceptance = request.localAcceptance;
135
- if (!acceptance || acceptance.instanceId !== connection.instanceId || acceptance.epoch !== connection.epoch || acceptance.sessionId !== sessionId || acceptance.provider !== agent.loginProvider || acceptance.workspaceId !== workspace.id || acceptance.bindingId !== row.bindingId)
101
+ const context = request.localContext;
102
+ if (!context || context.instanceId !== connection.instanceId || context.epoch !== connection.epoch || context.workspaceId !== workspace.id || context.bindingId !== row.bindingId)
136
103
  throw fail('LOCAL_USER_TURN_UNCONFIRMED');
104
+ if (typeof api.onWorkspaceLocationState !== 'function' || typeof api.onConnectionState !== 'function')
105
+ throw fail('DEPENDENCY_REQUIRED');
106
+ const network = employee.getNetwork();
107
+ if (!network || typeof network.prepareEmployeeEndpoint !== 'function' || typeof network.onState !== 'function')
108
+ throw fail('DEPENDENCY_REQUIRED');
109
+ if (network.getState().phase !== 'online')
110
+ throw fail('EMPLOYEE_ENDPOINT_UNAVAILABLE');
111
+ const revoke = (reason, initiator, changes) => { lifetime.abort(reason); if (candidate)
112
+ void candidate.dispose(reason, { layer: 'endpoint', initiator, reason: reason.code, action: 'invalidate', changes }).catch(() => { }); };
113
+ disposers.push(api.onWorkspaceLocationState(next => {
114
+ const matches = next.items.filter(item => item.workspaceId === context.workspaceId);
115
+ if (get() !== api || next.phase !== 'ready' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch || matches.length !== 1 || matches[0].bindingId !== context.bindingId || matches[0].source !== 'local' || matches[0].status !== 'selected')
116
+ revoke(fail('EMPLOYEE_CONTEXT_CHANGED'), 'endpoint.workspace-change', [{ field: 'workspace.binding-id', before: context.bindingId, after: matches[0]?.bindingId ?? null }, { field: 'workspace.source', before: 'local', after: matches[0]?.source ?? null }, { field: 'workspace.status', before: 'selected', after: matches[0]?.status ?? null }, { field: 'workspace.match-count', before: 1, after: matches.length }]);
117
+ }), api.onConnectionState(next => { if (get() !== api || next.phase !== 'connected' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch)
118
+ revoke(fail('CONNECTION_LOST'), 'endpoint.connection-change', [{ field: 'connection.instance-id', before: connection.instanceId, after: next.instanceId }, { field: 'connection.epoch', before: connection.epoch, after: next.epoch }, { field: 'connection.phase', before: 'connected', after: next.phase }]); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
119
+ revoke(fail('CONNECTION_LOST'), 'endpoint.network-loss', [{ field: 'network.phase', before: null, after: event.state.phase }]); }));
137
120
  details = await options.details?.(request, api, connection, target);
138
- if (details)
139
- request.timing?.bind(detail => details.record(detail));
140
121
  const sink = details;
141
- closeDiagnostics = prior?.runtime.closeDiagnostics ?? new CloseDiagnostics(event => sink?.record(event));
122
+ closeDiagnostics = new CloseDiagnostics(event => sink?.record(event));
142
123
  closeDiagnostics.bind(event => sink?.record(event));
143
124
  request.closeDiagnostics = closeDiagnostics;
144
- const unsupported = request.mode !== 'session' || agent.loginProvider !== 'codex' || !!request.recoveryCheckpointId || !!request.resumeSessionId && !prior;
125
+ const unsupported = agent.loginProvider !== 'codex';
145
126
  if (unsupported)
146
127
  throw fail('EMPLOYEE_OPERATION_UNSUPPORTED');
147
128
  if (!Number.isSafeInteger(request.acpMaxMessageBytes) || request.acpMaxMessageBytes < 1)
148
129
  throw fail('SETTINGS_REQUIRED');
149
- if (typeof api.onWorkspaceLocationState !== 'function' || typeof api.onConnectionState !== 'function')
150
- throw fail('DEPENDENCY_REQUIRED');
151
- const network = employee.getNetwork();
152
- if (!network || typeof network.prepareEmployeeEndpoint !== 'function' || typeof network.onState !== 'function')
153
- throw fail('DEPENDENCY_REQUIRED');
154
- if (prior && prior.network !== network) {
155
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
156
- throw fail('EMPLOYEE_CONTEXT_CHANGED');
157
- }
158
- const admitted = await admitEmployeeLocation({ api: api, network, workspace, connection, expectedLocal: { instanceId: acceptance.instanceId, epoch: acceptance.epoch, workspaceId: acceptance.workspaceId, bindingId: acceptance.bindingId, directory: { ...acceptance.directory } }, settingsPath: target.session.settingsPath, signal: request.signal });
130
+ const admitted = await admitEmployeeLocation({ api: api, network, workspace, connection, expectedLocal: { instanceId: context.instanceId, epoch: context.epoch, workspaceId: context.workspaceId, bindingId: context.bindingId, directory: { ...context.directory } }, settingsPath: target.session.settingsPath, signal: roundSignal });
159
131
  if (request.cwd && request.cwd !== admitted.cwd || request.bindingId && request.bindingId !== admitted.bindingId || request.workspaceId && request.workspaceId !== admitted.workspaceId)
160
132
  throw fail('SESSION_CONTEXT_MISMATCH');
161
133
  const native = await employee.native();
162
- if (!absolute(native.path) || !/^[a-f0-9]{64}$/.test(native.sha256))
134
+ roundSignal.throwIfAborted();
135
+ if (!absolute(native.path))
163
136
  throw fail('EMPLOYEE_BINARY_REQUIRED');
164
137
  // Only executable identity belongs in this scope. Diagnostic metadata and object key
165
138
  // insertion order cannot revoke a live binding; every public identity field stays bound.
@@ -171,33 +144,9 @@ export function createEmployeeProvider(get, options, agent) {
171
144
  local: { instanceId: local.instanceId, epoch: local.epoch, workspaceId: local.workspaceId, bindingId: local.bindingId, directory: { path: local.directory.path, device: local.directory.device, inode: local.directory.inode } },
172
145
  endpoint: { deviceId: account.deviceId, endpointInstanceId: account.endpointInstanceId, executionContextId: account.executionContextId, remoteUser: account.remoteUser, remoteUid: account.remoteUid, remoteHome: account.remoteHome, endpointCwd: account.endpointCwd, endpointShell: account.endpointShell },
173
146
  bridge: { protocolVersion: bridge.protocolVersion, command: bridge.command, settingsPath: bridge.settingsPath, bridgeInstanceId: bridge.bridgeInstanceId, generation: bridge.generation } },
174
- native: { path: native.path, sha256: native.sha256 },
147
+ native: { path: native.path },
175
148
  });
176
- if (prior && (prior.scope !== scope || prior.ended))
177
- await prior.dispose(fail('EMPLOYEE_CONTEXT_CHANGED'), { layer: 'endpoint', initiator: 'provider.scope-change', reason: 'EMPLOYEE_CONTEXT_CHANGED', action: 'dispose' });
178
- let entry = prior && prior.scope === scope && !prior.ended ? prior : undefined;
179
- let candidate = entry;
180
- // Admission still verifies the real directory and N generation. Per-RPC gates below
181
- // reuse the subscribed connection/location/network facts, without another SSH.
182
- await admitted.validate(request.signal ?? new AbortController().signal);
183
- details?.record({ kind: 'session', severity: 'info', code: 'EMPLOYEE_EXECUTION_VALIDATION_POLICY', summary: '本机执行核验不可关闭:L接纳核对实际目录与N端点;同代执行通道仅在open时核对一次主机target与固定执行器。之后每条消息只核对内存中的连接、工作区绑定和已接纳输入,撤销沿现有连接/位置/N网络事件及执行SSH关闭处理;不重复SSH,不改用主机执行。' });
184
- if (entry)
185
- entry.current = input;
186
- const base = async (signal) => {
187
- signal.throwIfAborted();
188
- if (get() !== api || employee.getNetwork() !== network)
189
- throw fail('EMPLOYEE_CONTEXT_CHANGED');
190
- if (candidate?.ended)
191
- throw candidate.reason ?? fail('EMPLOYEE_EXEC_CLOSED');
192
- const current = candidate?.current ?? input;
193
- current.request.localAdmissionCheck?.();
194
- await current.stable(signal);
195
- if (get() !== api || employee.getNetwork() !== network)
196
- throw fail('EMPLOYEE_CONTEXT_CHANGED');
197
- current.request.localAdmissionCheck?.();
198
- await current.stable(signal);
199
- signal.throwIfAborted();
200
- };
149
+ details?.record({ kind: 'session', severity: 'info', code: 'EMPLOYEE_EXECUTION_VALIDATION_POLICY', summary: '每轮开始核验本机所选目录、当前网络及端点,并重新prepare一次;本轮执行通道open时核验主机目标。之后不逐消息、审批或原生RPC重复核验。已有连接、位置、网络事件和通道关闭只终止本轮,不回退主机,下一轮重新核验。' });
201
150
  let argv = employee.argv;
202
151
  if (!argv && !employee.io) {
203
152
  const name = '@ai-workbench/connect';
@@ -214,98 +163,33 @@ export function createEmployeeProvider(get, options, agent) {
214
163
  void candidate.dispose(fail('EMPLOYEE_EXEC_UNKNOWN'), { layer: 'runtime', initiator: 'provider.child-close', reason: 'EMPLOYEE_EXEC_UNKNOWN', action: 'dispose' }).catch(() => { }); });
215
164
  return child;
216
165
  } };
217
- const ssh = { closeDiagnostics, target, endpoint: admitted.endpoint, employeeBinary: native.path, binarySha256: native.sha256, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), validate: base, io: scopedIo };
218
- const validate = async (signal) => { try {
219
- await base(signal);
220
- }
221
- catch (error) {
222
- if (candidate && errorCode(error) !== 'ABORTED')
223
- void candidate.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.validation-failure', reason: closeCode(error), action: 'invalidate' }).catch(cleanup => closeDiagnostics.record({ layer: 'runtime', initiator: 'provider.validation-cleanup', reason: closeCode(cleanup, 'REMOTE_CLEANUP_UNCONFIRMED'), action: 'failure' }));
224
- throw error;
225
- } };
226
- await validate(request.signal ?? new AbortController().signal);
166
+ const ssh = { closeDiagnostics, target, endpoint: admitted.endpoint, employeeBinary: native.path, acpMaxMessageBytes: request.acpMaxMessageBytes, argv: argv ?? (() => []), io: scopedIo };
227
167
  if (!options.open)
228
168
  throw fail('RUNTIME_REQUIRED');
229
169
  // Host cwd is only the protected agent bootstrap/config directory. Native tools use admitted.cwd.
230
- const host = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, closeDiagnostics, detailChannel: details, validateContext: async (ignore = false) => {
231
- // Disposal addresses only the already-owned host broker instance through its frozen IO/CAS.
232
- // A withdrawn local source must prevent dispatch, but cannot forbid that instance's exact kill.
233
- if (ignore)
234
- await (candidate?.current ?? input).stable();
235
- else
236
- await validate(request.signal ?? new AbortController().signal);
237
- }, resumeSessionId: undefined, recoveryCheckpointId: undefined, verifyRecovery: undefined });
238
- if (entry) {
239
- entry.current = input;
240
- entry.details = details;
241
- try {
242
- await validate(request.signal ?? new AbortController().signal);
243
- }
244
- catch (error) {
245
- await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
246
- throw error;
247
- }
248
- entry.runtime.details = details;
249
- entry.runtime.connection = { ...connection };
250
- entry.runtime.binding = { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision };
251
- entry.runtime.restartNotice = host.restartNotice;
252
- entry.runtime.restartInputIds = host.restartInputIds;
253
- entry.runtime.freshSessionConfirmed = host.freshSessionConfirmed;
254
- return entry.runtime;
255
- }
256
- const disposers = [];
257
- const execution = new EmployeeExecution({ closeDiagnostics, bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, validate, transport: signal => createEmployeeSshTransport(ssh, signal) });
258
- const runtime = { ...host, closeDiagnostics, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, saveCheckpoint: undefined, recoveredSession: undefined, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes, httpMcpPolicy: request.employeeHttpMcpPolicy ?? 'ignore' }, employeePresentation: CODEX_EMPLOYEE_SCOPE_NOTICE };
170
+ const host = opening = await options.open(target, { name: 'employee', path: target.projectRoot }, { ...request, signal: roundSignal, agent, employeeScope: scope, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, bindingRevision: state.revision, closeDiagnostics, detailChannel: details, resumeSessionId: request.resumeSessionId });
171
+ roundSignal.throwIfAborted();
172
+ // The old per-RPC identity check belongs once at the end of this round's opening.
173
+ if (get() !== api || employee.getNetwork() !== network)
174
+ throw fail('EMPLOYEE_CONTEXT_CHANGED');
175
+ const execution = new EmployeeExecution({ closeDiagnostics, bindingId: admitted.bindingId, acpMaxMessageBytes: request.acpMaxMessageBytes, transport: signal => createEmployeeSshTransport(ssh, signal) });
176
+ const runtime = { ...host, closeDiagnostics, cwd: admitted.cwd, details, connection: { ...connection }, binding: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, revision: state.revision }, employeeExecutionMeta: { version: 1, bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, cwd: admitted.cwd, acpMaxMessageBytes: request.acpMaxMessageBytes }, employeePresentation: EMPLOYEE_EXECUTION_NOTICE };
259
177
  runtime.employeeRemoteCloseReason = () => execution.remoteClose?.reason;
260
178
  entry = { scope, runtime, current: input, details, execution, network, ended: false, dispose: (reason, cause = { layer: 'runtime', initiator: 'provider.dispose', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'dispose' }) => {
261
179
  closeDiagnostics.record({ ...cause, changes: cause.changes ?? [{ field: 'provider.ended', before: entry.ended, after: true }] });
262
- if (entry.closing)
263
- return entry.closing;
180
+ if (entry.ended)
181
+ return Promise.resolve();
264
182
  entry.ended = true;
265
183
  entry.reason = reason;
184
+ if (live.get(sessionId) === entry)
185
+ live.delete(sessionId);
266
186
  execution.invalidate(reason ?? fail('EMPLOYEE_EXEC_CLOSED'), { layer: 'executor', initiator: 'provider.invalidate-executor', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', action: 'invalidate' });
267
187
  for (const off of disposers)
268
188
  off();
269
189
  host.broker?.fail(reason ?? fail('EMPLOYEE_EXEC_CLOSED'));
270
- entry.closing = (async () => {
271
- const cleanup = [], reporting = [];
272
- try {
273
- await execution.dispose({ layer: 'executor', initiator: 'provider.cleanup-executor', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
274
- }
275
- catch (error) {
276
- cleanup.push(...closeErrorCodes(error, 'EXECUTOR_CLEANUP_FAILED'));
277
- }
278
- try {
279
- await host.dispose?.({ layer: 'broker', initiator: 'provider.cleanup-host', reason: 'RUNTIME_CLEANUP', action: 'dispose' });
280
- }
281
- catch (error) {
282
- cleanup.push(...closeErrorCodes(error, 'HOST_CLEANUP_FAILED'));
283
- }
284
- const firstReason = String(closeDiagnostics.first?.reason ?? reason?.code ?? 'EMPLOYEE_EXEC_CLOSED');
285
- try {
286
- entry.details?.record({ kind: 'need', severity: 'warning', code: cleanup.length ? 'REMOTE_CLEANUP_UNCONFIRMED' : 'EMPLOYEE_EXEC_UNKNOWN', summary: '员工执行通道已关闭,命令结果 UNKNOWN', payload: { bindingId: admitted.bindingId, workspaceId: admitted.workspaceId, result: 'UNKNOWN', reason: reason?.code ?? 'EMPLOYEE_EXEC_CLOSED', firstReason, cleanup: cleanup.length ? 'unconfirmed' : 'closed', cleanupCodes: cleanup } });
287
- }
288
- catch (error) {
289
- reporting.push(closeCode(error, 'CLOSE_RECORD_FAILED'));
290
- }
291
- try {
292
- await entry.details?.flush();
293
- }
294
- catch (error) {
295
- reporting.push(...closeErrorCodes(error, 'CLOSE_FLUSH_FAILED'));
296
- }
297
- try {
298
- closeDiagnostics.assertRecorded();
299
- }
300
- catch (error) {
301
- reporting.push(closeDiagnostics.recordingFailureCode ?? closeCode(error, 'CLOSE_RECORD_FAILED'));
302
- }
303
- if (cleanup.length || reporting.length)
304
- throw Object.assign(new NeedError(reporting.length ? 'CONVERSATION_DETAILS_FAILED' : 'REMOTE_CLEANUP_UNCONFIRMED', '执行连接已关闭;首次原因 ' + firstReason + ',清理或诊断保存未确认。', '首次关闭 ' + firstReason + ';清理 ' + (cleanup.join(',') || 'closed') + ';保存 ' + (reporting.join(',') || 'recorded')), { cause: new AggregateError([firstReason, ...cleanup, ...reporting].map(code => new NeedError(code, '关闭收尾代码 ' + code, '技术详情中的固定代码')), 'EMPLOYEE_CLOSE_AND_DIAGNOSTIC_FAILURES') });
305
- if (live.get(sessionId) === entry)
306
- live.delete(sessionId);
307
- })();
308
- return entry.closing;
190
+ void Promise.allSettled([execution.dispose({ layer: 'executor', initiator: 'provider.cleanup-executor', reason: 'RUNTIME_CLEANUP', action: 'dispose' }), host.dispose?.({ layer: 'broker', initiator: 'provider.cleanup-host', reason: 'RUNTIME_CLEANUP', action: 'dispose' })]).then(results => { if (results.some(result => result.status === 'rejected'))
191
+ console.warn('[remote-agent] 员工执行通道的旧资源将由主机回收'); });
192
+ return Promise.resolve();
309
193
  } };
310
194
  candidate = entry;
311
195
  runtime.employeeExecution = async (params, signal) => {
@@ -324,43 +208,23 @@ export function createEmployeeProvider(get, options, agent) {
324
208
  };
325
209
  runtime.dispose = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.dispose', reason: 'RUNTIME_DISPOSE', action: 'dispose' });
326
210
  runtime.detach = cause => entry.dispose(undefined, cause ?? { layer: 'runtime', initiator: 'runtime.detach', reason: 'RUNTIME_DETACH', action: 'detach' });
327
- const revoke = (reason, initiator, changes) => { void entry.dispose(reason, { layer: 'endpoint', initiator, reason: reason.code, action: 'invalidate', changes }).catch(() => { }); };
328
- disposers.push(api.onWorkspaceLocationState(next => {
329
- const matches = next.items.filter(item => item.workspaceId === admitted.workspaceId);
330
- if (get() !== api || next.phase !== 'ready' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch || matches.length !== 1 || matches[0].bindingId !== admitted.bindingId || matches[0].source !== 'local' || matches[0].status !== 'selected')
331
- revoke(fail('EMPLOYEE_CONTEXT_CHANGED'), 'endpoint.workspace-change', [{ field: 'workspace.binding-id', before: admitted.bindingId, after: matches[0]?.bindingId ?? null }, { field: 'workspace.source', before: 'local', after: matches[0]?.source ?? null }, { field: 'workspace.status', before: 'selected', after: matches[0]?.status ?? null }, { field: 'workspace.match-count', before: 1, after: matches.length }]);
332
- }), api.onConnectionState(next => { if (get() !== api || next.phase !== 'connected' || next.instanceId !== connection.instanceId || next.epoch !== connection.epoch)
333
- revoke(fail('CONNECTION_LOST'), 'endpoint.connection-change', [{ field: 'connection.instance-id', before: connection.instanceId, after: next.instanceId }, { field: 'connection.epoch', before: connection.epoch, after: next.epoch }, { field: 'connection.phase', before: 'connected', after: next.phase }]); }), network.onState(event => { if (event.kind === 'lost' || employee.getNetwork() !== network)
334
- revoke(fail('CONNECTION_LOST'), 'endpoint.network-loss', [{ field: 'network.phase', before: null, after: event.state.phase }]); }));
335
211
  if (host.broker)
336
212
  disposers.push(host.broker.onChange(() => { if (!host.broker.online)
337
213
  revoke(fail('EMPLOYEE_EXEC_UNKNOWN'), 'endpoint.broker-offline'); }));
338
214
  live.set(sessionId, entry);
339
- try {
340
- await validate(request.signal ?? new AbortController().signal);
341
- }
342
- catch (error) {
343
- await entry.dispose(asNeed(error), { layer: 'endpoint', initiator: 'provider.final-validation', reason: closeCode(error), action: 'dispose' });
344
- throw error;
345
- }
346
215
  return runtime;
347
216
  }
348
217
  catch (error) {
349
- const reporting = [];
350
- try {
351
- details?.record({ kind: 'need', severity: 'action-required', code: errorCode(error), summary: asNeed(error).message, payload: { result: 'UNKNOWN' } });
352
- }
353
- catch (failure) {
354
- reporting.push(failure);
355
- }
218
+ for (const off of disposers)
219
+ off();
220
+ if (opening && !candidate)
221
+ void opening.dispose?.({ layer: 'runtime', initiator: 'provider.open-failure', reason: closeCode(error), action: 'dispose' }).catch(() => console.warn('EMPLOYEE_CLEANUP_FAILED'));
356
222
  try {
357
- await details?.flush();
223
+ details?.record({ kind: 'need', severity: 'warning', code: errorCode(error), summary: '本轮执行连接中断,恢复后可在原会话继续。', payload: { reason: errorCode(error) } });
358
224
  }
359
- catch (failure) {
360
- reporting.push(failure);
225
+ catch {
226
+ console.warn('[remote-agent] 执行诊断未保存');
361
227
  }
362
- if (reporting.length)
363
- throw withFailureEvidence(error, reporting, 'diagnostic', closeDiagnostics?.first?.reason);
364
228
  throw error;
365
229
  }
366
230
  };
@@ -7,7 +7,6 @@ export const employeeAdapterCodes = new Set([
7
7
  "AW_EMPLOYEE_CONFIG_ADMISSION_INVALID",
8
8
  "AW_EMPLOYEE_CONFIG_ADMISSION_FAILED",
9
9
  "AW_EMPLOYEE_HOST_PROJECT_CONFIG_UNSUPPORTED",
10
- "AW_EMPLOYEE_EXECUTOR_MCP_UNSUPPORTED",
11
10
  "AW_EMPLOYEE_EXECUTOR_CONFIG_PROTOCOL_INVALID",
12
11
  "AW_EMPLOYEE_EXECUTOR_CONFIG_READ_FAILED",
13
12
  "AW_EMPLOYEE_AGENT_CONFIG_UNAVAILABLE",
@@ -21,6 +20,7 @@ export const employeeAdapterCodes = new Set([
21
20
  "AW_EMPLOYEE_ENVIRONMENT_REGISTRATION_FAILED",
22
21
  "AW_EMPLOYEE_ENVIRONMENT_REQUIRED",
23
22
  "AW_EMPLOYEE_EXECUTION_CLOSED",
23
+ "AW_EMPLOYEE_NATIVE_CLOSED_BEFORE_INITIALIZE_REPLY",
24
24
  "AW_EMPLOYEE_EXECUTION_ID_REQUIRED",
25
25
  "AW_EMPLOYEE_HOST_AUXILIARY_CONFIG_UNSUPPORTED",
26
26
  "AW_EMPLOYEE_HOST_AUXILIARY_UNSUPPORTED",
@@ -40,10 +40,6 @@ export const employeeAdapterCodes = new Set([
40
40
  ]);
41
41
  export const employeeOpenCauseCodes = new Set([
42
42
  "AW_EMPLOYEE_BINARY_UNAVAILABLE",
43
- "AW_EMPLOYEE_BINARY_HASH_FAILED",
44
- "AW_EMPLOYEE_BINARY_HASH_MISMATCH",
45
- "AW_EMPLOYEE_BINARY_VERSION_FAILED",
46
- "AW_EMPLOYEE_BINARY_VERSION_MISMATCH",
47
43
  "EMPLOYEE_CONTEXT_CHANGED",
48
44
  "EMPLOYEE_ENDPOINT_INVALID",
49
45
  "EMPLOYEE_BINARY_REQUIRED",
@@ -16,6 +16,7 @@ export type EmployeeShell = (request: {
16
16
  interface Options {
17
17
  closeDiagnostics?: CloseDiagnostics;
18
18
  target: EmployeeEndpointTarget;
19
+ sshArgs: readonly string[];
19
20
  maxBytes: number;
20
21
  killGraceMs: number;
21
22
  attach: (spec: {
@@ -23,6 +24,6 @@ interface Options {
23
24
  args: string[];
24
25
  }) => Promise<ChildProcessWithoutNullStreams>;
25
26
  }
26
- /** Every invocation re-discovers the exact S target. Cancellation cannot prove remote exit. */
27
+ /** Use only the target verified at this round lease opening. Cancellation cannot prove remote exit. */
27
28
  export declare function createEmployeeShell(options: Options): EmployeeShell;
28
29
  export {};
@@ -1,24 +1,28 @@
1
1
  import { closeCode } from "./close-diagnostics.js";
2
2
  import { randomUUID } from 'node:crypto';
3
- import { EMPLOYEE_TARGET_PYTHON, EMPLOYEE_PYTHON_ERRORS } from "./employee-relay.js";
3
+ import { EMPLOYEE_PYTHON_ERRORS } from "./employee-relay.js";
4
4
  import { NeedError } from "./remote.js";
5
- const SHELL_PYTHON = EMPLOYEE_TARGET_PYTHON + String.raw `
6
- cwd=x["directory"]["path"];device=x["directory"]["device"];inode=x["directory"]["inode"]
5
+ const SHELL_PYTHON = String.raw `
6
+ import sys,json,subprocess,shlex
7
+ def fail(code):
8
+ sys.stderr.write(json.dumps({"code":code})+"\n");sys.exit(254)
9
+ try:
10
+ x=json.loads(sys.argv[1]);e=x["endpoint"];args=x["sshArgs"];cwd=x["cwd"]
7
11
  # User source is stdin, never argv. The sentinel preserves trailing newlines.
8
- wrapper="cd -- "+shlex.quote(cwd)+" || exit 254; "+'test "$(/usr/bin/stat -f '+shlex.quote('%d %i')+' .)" = '+shlex.quote(str(device)+' '+str(inode))+" || exit 254; "+'test "$(/usr/bin/id -u)" = '+shlex.quote(str(e['remoteUid']))+" || exit 254; "+"printf '%s\\n' "+shlex.quote('AW_EMPLOYEE_SHELL_STARTED '+x['executionId'])+" >&2; "+'aw_x_source=$(/bin/cat; printf x); aw_x_source='+chr(36)+'{aw_x_source%?}; eval "'+chr(36)+'aw_x_source"'
12
+ wrapper="cd -- "+shlex.quote(cwd)+" || exit 254; "+"printf '%s\\n' "+shlex.quote('AW_EMPLOYEE_SHELL_STARTED '+x['executionId'])+" >&2; "+'aw_x_source=$(/bin/cat; printf x); aw_x_source='+chr(36)+'{aw_x_source%?}; eval "'+chr(36)+'aw_x_source"'
9
13
  command=shlex.join([e["endpointShell"],"-c",wrapper])
10
14
  sys.stderr.write("AW_EMPLOYEE_SHELL_READY "+x["executionId"]+"\n");sys.stderr.flush()
11
15
  p=subprocess.Popen(["/usr/bin/ssh","-T"]+args+[command],stdin=sys.stdin,stdout=sys.stdout,stderr=sys.stderr)
12
16
  code=p.wait();sys.exit(code if code>=0 else 255)
13
17
  ` + EMPLOYEE_PYTHON_ERRORS;
14
18
  const unknown = (code) => new NeedError(code, '本机命令结果未知;没有重放,也未改在主机执行。', '员工端真实退出回执');
15
- /** Every invocation re-discovers the exact S target. Cancellation cannot prove remote exit. */
19
+ /** Use only the target verified at this round lease opening. Cancellation cannot prove remote exit. */
16
20
  export function createEmployeeShell(options) {
17
21
  return async ({ command, timeout, signal }) => {
18
22
  signal.throwIfAborted();
19
23
  const diagnostics = options.closeDiagnostics?.child();
20
24
  const executionId = randomUUID(), target = options.target;
21
- const child = await options.attach({ command: '/usr/bin/python3', args: ['-c', SHELL_PYTHON, JSON.stringify({ endpoint: target.endpoint, bridge: target.bridge, directory: target.local.directory, maxBytes: options.maxBytes, executionId })] });
25
+ const child = await options.attach({ command: '/usr/bin/python3', args: ['-c', SHELL_PYTHON, JSON.stringify({ endpoint: target.endpoint, sshArgs: options.sshArgs, cwd: target.local.directory.path, executionId })] });
22
26
  return new Promise((yes, no) => {
23
27
  let settled = false, bytes = 0, stdout = [], stderr = [], reason, grace, timer;
24
28
  const finish = (error, code) => {
@@ -65,13 +69,6 @@ export function createEmployeeShell(options) {
65
69
  no(unknown('EMPLOYEE_EXIT_UNKNOWN'));
66
70
  return;
67
71
  }
68
- try {
69
- diagnostics?.assertRecorded();
70
- }
71
- catch (failure) {
72
- no(failure);
73
- return;
74
- }
75
72
  yes({ stdout: out, stderr: err.slice(header.length, start) + err.slice(start + started.length), stderrSource: 'combined-ssh-and-command', exitCode: code, signal: null });
76
73
  };
77
74
  const stop = (code) => { diagnostics?.record({ layer: 'executor', initiator: 'shell.stop', reason: code, action: 'terminate', changes: [{ field: 'shell.stop-code', before: reason?.code ?? null, after: settled || reason ? reason?.code ?? null : code }] }); if (settled || reason)
@@ -9,15 +9,13 @@ export interface EmployeeSshOptions {
9
9
  closeDiagnostics?: CloseDiagnostics;
10
10
  /** Automatically discovered package binary path on this same employee device. */
11
11
  employeeBinary: string;
12
- binarySha256: string;
13
12
  acpMaxMessageBytes: number;
14
13
  argv: SshArgv;
15
- validate(signal: AbortSignal): Promise<void>;
16
14
  /** Fixture seam only. Production uses L's exported argv through sshIo. */
17
15
  io?: BrokerIo;
18
16
  }
19
17
  /** Fixed bootstrap only; cwd for every native operation remains in its original RPC. */
20
- export declare function employeeExecServerCommand(binary: string, binarySha256: string): string;
18
+ export declare function employeeExecServerCommand(binary: string, readyMarker: string): string;
21
19
  /** Explicit read-only probe; production dispatch verifies S once in transport open. */
22
20
  export declare function validateEmployeeSshTarget(options: EmployeeSshOptions, signal?: AbortSignal): Promise<void>;
23
21
  /** Outer L-authenticated host SSH, then S-authenticated employee SSH; no host execution fallback. */
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { closeCode } from "./close-diagnostics.js";
2
3
  import { sshIo } from "./broker.js";
3
4
  import { createEmployeeProcessTransport } from "./employee-exec.js";
@@ -70,17 +71,10 @@ function readTarget(raw, expected, maxBytes) {
70
71
  return args;
71
72
  }
72
73
  /** Fixed bootstrap only; cwd for every native operation remains in its original RPC. */
73
- export function employeeExecServerCommand(binary, binarySha256) {
74
- if (!absolute(binary) || !hash(binarySha256))
74
+ export function employeeExecServerCommand(binary, readyMarker) {
75
+ if (!absolute(binary))
75
76
  throw fail('EMPLOYEE_BINARY_REQUIRED');
76
- const file = shellQuote(binary), digest = shellQuote(binarySha256);
77
- const stop = (code) => `{ printf '%s\\n' '${code}' >&2; exit 126; }`;
78
- return `if [ ! -f ${file} ] || [ ! -x ${file} ]; then ${stop('AW_EMPLOYEE_BINARY_UNAVAILABLE')}; fi; ` +
79
- `aw_employee_digest=$(/usr/bin/shasum -a 256 < ${file} 2>/dev/null) || ${stop('AW_EMPLOYEE_BINARY_HASH_FAILED')}; ` +
80
- `aw_employee_digest=\${aw_employee_digest%% *}; [ "$aw_employee_digest" = ${digest} ] || ${stop('AW_EMPLOYEE_BINARY_HASH_MISMATCH')}; ` +
81
- `aw_employee_version=$(${file} --version 2>/dev/null) || ${stop('AW_EMPLOYEE_BINARY_VERSION_FAILED')}; ` +
82
- `[ "$aw_employee_version" = 'codex-cli 0.160.1' ] || ${stop('AW_EMPLOYEE_BINARY_VERSION_MISMATCH')}; ` +
83
- `exec ${file} exec-server --listen stdio`;
77
+ return `printf '%s\n' ${shellQuote(readyMarker)} >&2; exec ${shellQuote(binary)} exec-server --listen stdio`;
84
78
  }
85
79
  async function currentTarget(options, signal) {
86
80
  checkSignal(signal);
@@ -89,14 +83,14 @@ async function currentTarget(options, signal) {
89
83
  const endpoint = structuredClone(options.endpoint), target = structuredClone(options.target);
90
84
  checkEndpoint(endpoint, target);
91
85
  const io = options.io ?? sshIo(target, options.argv, options.acpMaxMessageBytes);
92
- const gate = async () => { checkSignal(signal); await awaitSignal(options.validate(signal), signal); checkSignal(signal); };
93
- await gate();
86
+ const gate = () => checkSignal(signal);
87
+ gate();
94
88
  const b = endpoint.bridge, e = endpoint.endpoint;
95
89
  const command = [b.command, 'target', '--settings', b.settingsPath, '--device', e.deviceId, '--if-instance', b.bridgeInstanceId, '--if-generation', String(b.generation), '--if-endpoint', e.endpointInstanceId, '--if-context', e.executionContextId].map(shellQuote).join(' ');
96
90
  const run = () => awaitSignal(io.run(command, signal), signal);
97
91
  const raw = await (options.closeDiagnostics ? options.closeDiagnostics.run('endpoint.target', run) : run());
98
92
  const args = readTarget(raw, endpoint, options.acpMaxMessageBytes);
99
- await gate();
93
+ gate();
100
94
  return { io, args, gate };
101
95
  }
102
96
  /** Explicit read-only probe; production dispatch verifies S once in transport open. */
@@ -104,18 +98,21 @@ export async function validateEmployeeSshTarget(options, signal = new AbortContr
104
98
  /** Outer L-authenticated host SSH, then S-authenticated employee SSH; no host execution fallback. */
105
99
  export async function createEmployeeSshTransport(options, signal = new AbortController().signal) {
106
100
  checkSignal(signal);
107
- const remote = employeeExecServerCommand(options.employeeBinary, options.binarySha256);
101
+ const readyMarker = 'AW_EMPLOYEE_EXEC_SERVER_READY_' + randomUUID().replaceAll('-', '');
102
+ const remote = employeeExecServerCommand(options.employeeBinary, readyMarker);
108
103
  const { io, args, gate } = await currentTarget(options, signal);
109
104
  checkSignal(signal);
110
105
  // EmployeeExecution races the user's open wait; this factory retains any late child cleanup.
111
106
  const child = await io.attach('exec ' + ['/usr/bin/ssh', ...args, remote].map(shellQuote).join(' '));
112
- const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, ['AW_EMPLOYEE_BINARY_UNAVAILABLE', 'AW_EMPLOYEE_BINARY_HASH_FAILED', 'AW_EMPLOYEE_BINARY_HASH_MISMATCH', 'AW_EMPLOYEE_BINARY_VERSION_FAILED', 'AW_EMPLOYEE_BINARY_VERSION_MISMATCH'], options.closeDiagnostics?.capture());
107
+ const transport = createEmployeeProcessTransport(child, options.acpMaxMessageBytes, [], options.closeDiagnostics?.capture(), readyMarker);
113
108
  if (signal.aborted) {
114
109
  await transport.close({ layer: 'endpoint', initiator: 'ssh.open-abort', reason: 'ABORTED', operation: 'open', action: 'abort' });
115
110
  throw fail('ABORTED');
116
111
  }
117
112
  try {
118
- await gate();
113
+ const wait = () => awaitSignal(transport.ready, signal);
114
+ await (options.closeDiagnostics ? options.closeDiagnostics.run('employee-executor-ready', wait) : wait());
115
+ gate();
119
116
  return transport;
120
117
  }
121
118
  catch (error) {