evolcore 0.0.18 → 0.0.20
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.
- package/CHANGELOG.md +39 -0
- package/README.md +2 -0
- package/bin/install-codex-managed-hooks.mjs +61 -0
- package/dist/agents/claude-runner.js +4 -3
- package/dist/agents/codex-runner.js +25 -20
- package/dist/agents/ecagent-runner.js +3 -2
- package/dist/aun/aid/agentmd.js +7 -0
- package/dist/aun/msg/group.js +14 -3
- package/dist/aun/msg/p2p.js +21 -11
- package/dist/aun/outbox.js +144 -19
- package/dist/channels/aun.js +621 -211
- package/dist/cli/daemon-commands.js +41 -8
- package/dist/cli/index.js +1 -0
- package/dist/cli/init.js +55 -15
- package/dist/cli/restart-monitor.js +3 -3
- package/dist/config/aun-gateway-config.js +2 -0
- package/dist/config/config-manager.js +92 -8
- package/dist/config/config-operation-service.js +1 -2
- package/dist/config/gateway-config.js +9 -7
- package/dist/config/lifecycle.js +16 -5
- package/dist/config-store.js +13 -6
- package/dist/core/auth/authorization-audit.js +5 -2
- package/dist/core/bootstrap-messages.js +2 -2
- package/dist/core/bootstrap-service.js +21 -36
- package/dist/core/channel-loader.js +0 -2
- package/dist/core/data-migration.js +10 -4
- package/dist/core/evolagent.js +5 -4
- package/dist/core/message/message-bridge.js +6 -11
- package/dist/core/message/response-engine.js +62 -6
- package/dist/core/permission/ec-command-parser.js +203 -24
- package/dist/core/permission/sandbox-runtime.js +46 -12
- package/dist/core/permission/tool-policy.js +116 -47
- package/dist/core/relation/peer-identity.js +18 -0
- package/dist/eck/kit-renderer.js +17 -8
- package/dist/index.js +30 -19
- package/dist/ipc.js +6 -1
- package/dist/paths.js +0 -3
- package/dist/utils/stats.js +52 -18
- package/dist/utils/welcome.js +2 -2
- package/kits/docs/path-registry.md +1 -1
- package/kits/rules/01-overview.md +1 -1
- package/kits/rules/02-navigation.md +2 -2
- package/kits/rules/03-identity.md +1 -1
- package/kits/rules/05-venue.md +1 -1
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.10.json +2 -1
- package/kits/schemas/agent-config.schema.11.json +408 -0
- package/kits/schemas/daemon.schema.5.json +136 -0
- package/kits/schemas/defaults.schema.5.json +107 -0
- package/package.json +2 -1
- package/dist/core/message/pause-controller.js +0 -53
|
@@ -5,7 +5,7 @@ import { kitsTemplatesDir, agentMdPath } from '../paths.js';
|
|
|
5
5
|
import { resolveAgentDisplayName } from '../aun/aid/agentmd.js';
|
|
6
6
|
import { logger } from '../utils/logger.js';
|
|
7
7
|
import { loadAgent, saveAgent } from '../config-store.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveAgentLifecycle, withLifecycleForWrite } from '../config/lifecycle.js';
|
|
9
9
|
import { renderTemplate } from '../eck/manifest-engine.js';
|
|
10
10
|
import { activeBaseagent } from './model/config-scope.js';
|
|
11
11
|
import { buildEnvelope } from './message/message-utils.js';
|
|
@@ -123,12 +123,18 @@ export class BootstrapService {
|
|
|
123
123
|
this.inFlight.delete(key);
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
126
|
+
const lifecycle = resolveAgentLifecycle(loadedConfig);
|
|
127
|
+
if (lifecycle === 'active') {
|
|
128
128
|
this.inFlight.delete(key);
|
|
129
129
|
return false;
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
if (lifecycle !== 'created' && lifecycle !== 'bootstrapping') {
|
|
132
|
+
this.inFlight.delete(key);
|
|
133
|
+
logger.warn(`[Bootstrap] Refusing invalid lifecycle for ${aid}: ${String(loadedConfig.lifecycle)}`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const config = { ...loadedConfig, lifecycle };
|
|
137
|
+
const starting = lifecycle === 'created';
|
|
132
138
|
const channelType = ctx.channelType || this.channelTypeFromKey(ctx.channelKey);
|
|
133
139
|
const configuredRecipient = this.resolveConfiguredRecipient(config, ctx.channelKey, channelType);
|
|
134
140
|
const recipientId = ctx.recipientId || configuredRecipient;
|
|
@@ -140,35 +146,14 @@ export class BootstrapService {
|
|
|
140
146
|
this.inFlight.delete(key);
|
|
141
147
|
return false;
|
|
142
148
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
isGroupRecipient = channelType === 'aun'
|
|
153
|
-
? (await ctx.adapter.isGroup?.(recipientId)) === true
|
|
154
|
-
: false;
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
// Group/private routing is security-sensitive. If the authoritative
|
|
158
|
-
// probe fails, do not guess a route, and release the single-flight key
|
|
159
|
-
// so a later connection or retry can recover.
|
|
160
|
-
this.inFlight.delete(key);
|
|
161
|
-
logger.warn(`[Bootstrap] Failed to resolve recipient type for ${aid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
162
|
-
return false;
|
|
163
|
-
}
|
|
164
|
-
const delivery = isGroupRecipient === true
|
|
165
|
-
? { chatType: 'group', groupId: recipientId }
|
|
166
|
-
: { chatType: 'private' };
|
|
167
|
-
const channelId = delivery.chatType === 'group'
|
|
168
|
-
? delivery.groupId
|
|
169
|
-
: channelType === 'aun'
|
|
170
|
-
? recipientId
|
|
171
|
-
: ctx.channelId || this.defaultChannelIdForConnection(channelType, recipientId);
|
|
149
|
+
// Bootstrap is initiated by connection/owner binding, not by a reply to
|
|
150
|
+
// an existing group message. `owners[]` contains personal Owner AIDs, so
|
|
151
|
+
// the first prompt must always use the private message route. Inbound
|
|
152
|
+
// group context must never change this initial delivery target.
|
|
153
|
+
const delivery = { chatType: 'private' };
|
|
154
|
+
const channelId = channelType === 'aun'
|
|
155
|
+
? recipientId
|
|
156
|
+
: ctx.channelId || this.defaultChannelIdForConnection(channelType, recipientId);
|
|
172
157
|
if (!channelId) {
|
|
173
158
|
this.inFlight.delete(key);
|
|
174
159
|
return false;
|
|
@@ -234,14 +219,14 @@ export class BootstrapService {
|
|
|
234
219
|
const loadedConfig = loadAgent(aid) || agent?.config;
|
|
235
220
|
if (!loadedConfig)
|
|
236
221
|
return { ok: false, error: `Agent "${aid}" not found` };
|
|
237
|
-
const lifecycle =
|
|
222
|
+
const lifecycle = resolveAgentLifecycle(loadedConfig);
|
|
238
223
|
if (lifecycle === 'active')
|
|
239
224
|
return { ok: true, aid, transitioned: false };
|
|
240
225
|
if (lifecycle !== 'bootstrapping') {
|
|
241
226
|
return {
|
|
242
227
|
ok: false,
|
|
243
228
|
code: 'INVALID_LIFECYCLE',
|
|
244
|
-
error: `Agent "${aid}" is ${lifecycle}; only a bootstrapping agent can become ready`,
|
|
229
|
+
error: `Agent "${aid}" is ${lifecycle ?? `invalid (${String(loadedConfig.lifecycle)})`}; only a bootstrapping agent can become ready`,
|
|
245
230
|
};
|
|
246
231
|
}
|
|
247
232
|
this.setLifecycle(agent, aid, 'active');
|
|
@@ -253,7 +238,7 @@ export class BootstrapService {
|
|
|
253
238
|
const agent = this.agentRegistry.get(aid);
|
|
254
239
|
const loadedConfig = loadAgent(aid) || agent?.config;
|
|
255
240
|
return loadedConfig
|
|
256
|
-
?
|
|
241
|
+
? resolveAgentLifecycle(loadedConfig)
|
|
257
242
|
: null;
|
|
258
243
|
}
|
|
259
244
|
setLifecycle(agent, aid, lifecycle) {
|
|
@@ -65,7 +65,6 @@ export class ChannelLoader {
|
|
|
65
65
|
owner: agent.getOwner(aunEffName),
|
|
66
66
|
enabled: true,
|
|
67
67
|
keystorePath: agent.config.aun?.keystorePath,
|
|
68
|
-
gatewayUrl: agent.config.aun?.gatewayUrl,
|
|
69
68
|
};
|
|
70
69
|
const configInsts = [aunInst];
|
|
71
70
|
for (const inst of agent.config.channels) {
|
|
@@ -255,7 +254,6 @@ export function buildReloadHooks(deps) {
|
|
|
255
254
|
aid,
|
|
256
255
|
enabled: true,
|
|
257
256
|
keystorePath: agent.config?.aun?.keystorePath,
|
|
258
|
-
gatewayUrl: agent.config?.aun?.gatewayUrl,
|
|
259
257
|
}
|
|
260
258
|
: (() => {
|
|
261
259
|
const agentChannels = agent.config?.channels ?? [];
|
|
@@ -1295,8 +1295,14 @@ function finalizationSourcePaths(root, manifest) {
|
|
|
1295
1295
|
function archiveHash(filePath) {
|
|
1296
1296
|
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
|
1297
1297
|
}
|
|
1298
|
-
function
|
|
1299
|
-
|
|
1298
|
+
function systemTar() {
|
|
1299
|
+
if (process.platform !== 'win32')
|
|
1300
|
+
return 'tar';
|
|
1301
|
+
// Git Bash puts GNU tar ahead of Windows bsdtar on PATH. GNU tar treats the
|
|
1302
|
+
// drive colon in an absolute archive path as host:path remote syntax.
|
|
1303
|
+
return path.win32.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe');
|
|
1304
|
+
}
|
|
1305
|
+
function verifyArchiveContains(archivePath, sourcePaths, tar) {
|
|
1300
1306
|
const normalizeArchivePath = (value) => value
|
|
1301
1307
|
.trim()
|
|
1302
1308
|
.replace(/\\/g, '/')
|
|
@@ -1402,14 +1408,14 @@ export function finalizeDataMigration(root, manifest) {
|
|
|
1402
1408
|
}
|
|
1403
1409
|
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
|
1404
1410
|
const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
1405
|
-
const tar =
|
|
1411
|
+
const tar = systemTar();
|
|
1406
1412
|
try {
|
|
1407
1413
|
execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
|
|
1408
1414
|
cwd: root,
|
|
1409
1415
|
stdio: 'pipe',
|
|
1410
1416
|
windowsHide: process.platform === 'win32',
|
|
1411
1417
|
});
|
|
1412
|
-
verifyArchiveContains(temporary, sourcePaths);
|
|
1418
|
+
verifyArchiveContains(temporary, sourcePaths, tar);
|
|
1413
1419
|
fs.renameSync(temporary, archivePath);
|
|
1414
1420
|
}
|
|
1415
1421
|
catch (error) {
|
package/dist/core/evolagent.js
CHANGED
|
@@ -243,16 +243,17 @@ export class EvolAgent {
|
|
|
243
243
|
b.mentionMode = value;
|
|
244
244
|
});
|
|
245
245
|
}
|
|
246
|
-
/** 读取观察者模式开关(默认 false
|
|
246
|
+
/** 读取观察者模式开关(默认 true;显式 false 可关闭)。 */
|
|
247
247
|
getObservable() {
|
|
248
|
-
return this.merged.observable
|
|
248
|
+
return this.merged.observable ?? true;
|
|
249
249
|
}
|
|
250
250
|
/** 设置观察者模式开关:开启后入站/出站消息各转发一份给 owners[]。 */
|
|
251
251
|
setObservable(value) {
|
|
252
|
+
// The default is enabled, so false must remain an explicit persisted override.
|
|
252
253
|
if (value)
|
|
253
|
-
this.rawAgent.observable = true;
|
|
254
|
-
else
|
|
255
254
|
delete this.rawAgent.observable;
|
|
255
|
+
else
|
|
256
|
+
this.rawAgent.observable = false;
|
|
256
257
|
this.merged.observable = value;
|
|
257
258
|
this.persist();
|
|
258
259
|
}
|
|
@@ -695,12 +695,6 @@ export class MessageBridge {
|
|
|
695
695
|
const channelKey = adapter?.channelKey || channelName;
|
|
696
696
|
const owningAgent = this.agentRegistry?.resolveByChannel(channelKey)
|
|
697
697
|
?? this.agentRegistry?.resolveByChannel(channelName);
|
|
698
|
-
// Agent-owned business controls bypass ResponseEngine entirely, so
|
|
699
|
-
// expose them only after bootstrap has explicitly reached active.
|
|
700
|
-
// Missing lifecycle fails closed; control channels without an owning
|
|
701
|
-
// EvolAgent keep their existing command behavior.
|
|
702
|
-
const allowsNormalCommandRouting = !owningAgent
|
|
703
|
-
|| owningAgent.config?.lifecycle === 'active';
|
|
704
698
|
const parsedChannelKey = tryParseChannelKey(channelKey);
|
|
705
699
|
const chatType = msg.chatType || 'private';
|
|
706
700
|
const delivery = chatType === 'group'
|
|
@@ -779,7 +773,10 @@ export class MessageBridge {
|
|
|
779
773
|
logger.info(`[MessageBridge] Inbound admission denied before business routing: self=${selfAid} actor=${actorId ?? '<none>'} channel=${channelKey} reason=${admissionDecision.reason}`);
|
|
780
774
|
return;
|
|
781
775
|
}
|
|
782
|
-
|
|
776
|
+
// Menu is a protocol control plane. Its parsing and response path must
|
|
777
|
+
// remain available throughout the agent lifecycle; lifecycle gates
|
|
778
|
+
// ordinary model execution, not protocol handling.
|
|
779
|
+
const menuControl = resolvedChannelType === 'aun'
|
|
783
780
|
? parseMenuControl(content)
|
|
784
781
|
: { isMenu: false };
|
|
785
782
|
let menuResponseWarning;
|
|
@@ -930,8 +927,7 @@ export class MessageBridge {
|
|
|
930
927
|
// 2. 命令快速路径(去除引用前缀后检查,兼容话题中引用上文的情况)
|
|
931
928
|
const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
|
|
932
929
|
const cmdContent = contentForCmd || content;
|
|
933
|
-
const isCmd =
|
|
934
|
-
&& msg.source !== 'handoff'
|
|
930
|
+
const isCmd = msg.source !== 'handoff'
|
|
935
931
|
&& this.cmdHandler.isCommand(cmdContent);
|
|
936
932
|
if (isCmd) {
|
|
937
933
|
logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
|
|
@@ -969,8 +965,7 @@ export class MessageBridge {
|
|
|
969
965
|
logger.debug(`[MessageBridge] Failed to log inbound command: ${e}`);
|
|
970
966
|
}
|
|
971
967
|
}
|
|
972
|
-
if (
|
|
973
|
-
&& msg.source !== 'handoff'
|
|
968
|
+
if (msg.source !== 'handoff'
|
|
974
969
|
&& await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => {
|
|
975
970
|
const taskId = `cmd-${msg.messageId || Date.now()}`;
|
|
976
971
|
logger.channelOut({ channel: channelName, channelId: msg.channelId, taskId, correlationId: taskId, sessionId: msg.replyContext?.sessionId, agentAid: msg.selfAID, payload: { kind: 'command.result', text } });
|
|
@@ -33,6 +33,7 @@ import { eligibleFallbackModels } from '../model/model-fallback.js';
|
|
|
33
33
|
import { RetryScheduler } from './retry-scheduler.js';
|
|
34
34
|
import { constrainRuntimePermissionMode, resolveRuntimeStringField } from '../role/runtime-policy.js';
|
|
35
35
|
import { resolveEffective, resolveEffectiveFieldWithSource } from '../../config/config-manager.js';
|
|
36
|
+
import { resolveAgentLifecycle } from '../../config/lifecycle.js';
|
|
36
37
|
import { authorizationConfigRevision, checkRoleAccess, getFirstStaticAgentOwner, listStaticAgentAdmins, listStaticAgentOwners, resolvePeerRoleDetail, roleToSessionIdentity, } from '../../config/peer-role-resolver.js';
|
|
37
38
|
import { insertUsageEvent, insertContextBreakdown, insertModelCalls } from '../../stats/writer.js';
|
|
38
39
|
import { normalizeUsage } from '../../stats/normalizer.js';
|
|
@@ -48,7 +49,58 @@ import { SessionTurnCoordinator } from '../session/session-turn-coordinator.js';
|
|
|
48
49
|
import { recordTriggerExecutionAnomaly } from '../../trigger/anomaly-store.js';
|
|
49
50
|
import { classifyToolErrorCode } from '../permission/tool-error-code.js';
|
|
50
51
|
import { buildToolLifecycleEventKey } from '../audit/event-key.js';
|
|
51
|
-
|
|
52
|
+
export class PauseController {
|
|
53
|
+
state = 'running';
|
|
54
|
+
waiters = new Set();
|
|
55
|
+
requestPause() {
|
|
56
|
+
if (this.state !== 'running')
|
|
57
|
+
return false;
|
|
58
|
+
this.state = 'pause_requested';
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
isPauseRequested() { return this.state === 'pause_requested'; }
|
|
62
|
+
isPaused() { return this.state === 'paused'; }
|
|
63
|
+
waitAtToolBoundary(signal) {
|
|
64
|
+
if (signal?.aborted)
|
|
65
|
+
return Promise.resolve('cancelled');
|
|
66
|
+
if (this.state === 'running')
|
|
67
|
+
return Promise.resolve('none');
|
|
68
|
+
this.state = 'paused';
|
|
69
|
+
return new Promise(resolve => {
|
|
70
|
+
const waiter = { resolve: () => { }, abort: undefined };
|
|
71
|
+
const finish = (result) => {
|
|
72
|
+
this.waiters.delete(waiter);
|
|
73
|
+
if (waiter.abort)
|
|
74
|
+
signal?.removeEventListener('abort', waiter.abort);
|
|
75
|
+
resolve(result);
|
|
76
|
+
};
|
|
77
|
+
waiter.resolve = finish;
|
|
78
|
+
if (signal) {
|
|
79
|
+
waiter.abort = () => finish('cancelled');
|
|
80
|
+
signal.addEventListener('abort', waiter.abort, { once: true });
|
|
81
|
+
}
|
|
82
|
+
this.waiters.add(waiter);
|
|
83
|
+
if (this.state === 'running')
|
|
84
|
+
finish('released');
|
|
85
|
+
else if (signal?.aborted)
|
|
86
|
+
finish('cancelled');
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
resume() {
|
|
90
|
+
if (this.state === 'running')
|
|
91
|
+
return false;
|
|
92
|
+
this.state = 'running';
|
|
93
|
+
for (const waiter of [...this.waiters])
|
|
94
|
+
waiter.resolve('released');
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
cancel() {
|
|
98
|
+
this.state = 'running';
|
|
99
|
+
for (const waiter of [...this.waiters])
|
|
100
|
+
waiter.resolve('cancelled');
|
|
101
|
+
}
|
|
102
|
+
clear() { this.cancel(); }
|
|
103
|
+
}
|
|
52
104
|
function isShowActivitiesMode(value) {
|
|
53
105
|
return value === 'all' || value === 'text' || value === 'none';
|
|
54
106
|
}
|
|
@@ -1555,7 +1607,9 @@ export class ResponseEngine {
|
|
|
1555
1607
|
const owningAgentForTask = this.agentRegistry?.resolveByChannel(channelKey)
|
|
1556
1608
|
?? (taskAgentAid ? this.agentRegistry?.get(taskAgentAid) : null);
|
|
1557
1609
|
const runnerSelfAid = owningAgentForTask?.aid || session.selfAID || message.selfAID;
|
|
1558
|
-
const lifecycle = owningAgentForTask
|
|
1610
|
+
const lifecycle = owningAgentForTask
|
|
1611
|
+
? resolveAgentLifecycle(owningAgentForTask.config)
|
|
1612
|
+
: undefined;
|
|
1559
1613
|
const isActive = lifecycle === 'active';
|
|
1560
1614
|
const isBootstrapping = lifecycle === 'bootstrapping';
|
|
1561
1615
|
// Per-method agent name for stats bucketing (agent.name or '<unknown>')
|
|
@@ -1566,10 +1620,12 @@ export class ResponseEngine {
|
|
|
1566
1620
|
return;
|
|
1567
1621
|
}
|
|
1568
1622
|
// Agent-owned turns are runnable only in the two explicit execution
|
|
1569
|
-
// states. An untransitioned `created` agent or
|
|
1570
|
-
//
|
|
1623
|
+
// states. An untransitioned `created` agent or malformed lifecycle must
|
|
1624
|
+
// not fall through to a base runner's default prompt. Missing lifecycle
|
|
1625
|
+
// is normalized to active for legacy configurations.
|
|
1571
1626
|
if (owningAgentForTask && !isActive && !isBootstrapping) {
|
|
1572
|
-
const blockedLifecycle = lifecycle
|
|
1627
|
+
const blockedLifecycle = lifecycle
|
|
1628
|
+
?? `invalid (${String(owningAgentForTask.config.lifecycle)})`;
|
|
1573
1629
|
logger.error(`[ResponseEngine] Agent lifecycle is not runnable: agent=${owningAgentForTask.aid} lifecycle=${blockedLifecycle}`);
|
|
1574
1630
|
this.publishTriggerExecutionFailure(message, `agent_lifecycle_not_runnable:${blockedLifecycle}`);
|
|
1575
1631
|
return;
|
|
@@ -1577,7 +1633,7 @@ export class ResponseEngine {
|
|
|
1577
1633
|
// 二次拦截:如果命令消息绕过 MessageBridge 的 handleCommand 泄漏到这里,
|
|
1578
1634
|
// 静默丢弃而不是发送给 Agent(命令已在 MessageBridge 层处理过)
|
|
1579
1635
|
const rawContent = message.content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
|
|
1580
|
-
if (
|
|
1636
|
+
if (rawContent.startsWith('/') && this.isKnownCommand(rawContent)) {
|
|
1581
1637
|
logger.warn(`[ResponseEngine] Command leaked past MessageBridge, dropped: "${rawContent.substring(0, 40)}"`);
|
|
1582
1638
|
this.publishTriggerExecutionFailure(message, 'trigger_command_not_supported');
|
|
1583
1639
|
return;
|
|
@@ -1,16 +1,73 @@
|
|
|
1
1
|
const HEAD_EXECUTABLES = new Set(['head', '/bin/head', '/usr/bin/head']);
|
|
2
2
|
// Codex app-server sends command executions through a fixed shell carrier.
|
|
3
3
|
// Treat the carrier as syntax only after parsing it as literal argv; never
|
|
4
|
-
// accept an arbitrary
|
|
5
|
-
const
|
|
4
|
+
// accept an arbitrary shell command string or unwrap composition here.
|
|
5
|
+
const CODEX_POSIX_SHELL_EXECUTABLES = new Set([
|
|
6
6
|
'bash', '/bin/bash', '/usr/bin/bash',
|
|
7
7
|
'sh', '/bin/sh', '/usr/bin/sh',
|
|
8
8
|
]);
|
|
9
|
+
const CODEX_POWERSHELL_EXECUTABLES = new Set([
|
|
10
|
+
'pwsh', 'pwsh.exe', 'powershell', 'powershell.exe',
|
|
11
|
+
]);
|
|
12
|
+
const CODEX_POWERSHELL_INSTALL_PATH_RE = /^[A-Za-z]:[\\/](?:Program Files[\\/]PowerShell[\\/][0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9.-]+)?[\\/]pwsh\.exe|Windows[\\/]System32[\\/]WindowsPowerShell[\\/]v1\.0[\\/]powershell\.exe)$/i;
|
|
9
13
|
const SHELL_UNQUOTED_META = new Set([
|
|
10
14
|
';', '&', '|', '`', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#',
|
|
11
15
|
]);
|
|
16
|
+
const POWERSHELL_UNQUOTED_META = new Set([
|
|
17
|
+
';', '&', '|', '<', '>', '$', '(', ')', '*', '?', '[', ']', '{', '}', '#', '@',
|
|
18
|
+
]);
|
|
12
19
|
const AMBIGUOUS_FILE_PATH_RE = /[\0\r\n$`*?\[\]{}<>|;&]/;
|
|
13
|
-
|
|
20
|
+
// This is intentionally limited to the command boundary. It is not a search
|
|
21
|
+
// for `ec` anywhere in the input: quoted arguments, regexps and log text must
|
|
22
|
+
// never establish an EC invocation.
|
|
23
|
+
function hasEcCommandPrefix(command) {
|
|
24
|
+
let quote = null;
|
|
25
|
+
let escaped = false;
|
|
26
|
+
let boundary = true;
|
|
27
|
+
for (let index = 0; index < command.length; index++) {
|
|
28
|
+
const char = command[index];
|
|
29
|
+
if (escaped) {
|
|
30
|
+
escaped = false;
|
|
31
|
+
boundary = false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (quote === 'single') {
|
|
35
|
+
if (char === "'")
|
|
36
|
+
quote = null;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (quote === 'double') {
|
|
40
|
+
if (char === '\\') {
|
|
41
|
+
escaped = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === '"')
|
|
45
|
+
quote = null;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === "'") {
|
|
49
|
+
quote = 'single';
|
|
50
|
+
boundary = false;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === '"') {
|
|
54
|
+
quote = 'double';
|
|
55
|
+
boundary = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (/[;&|`()<>]/.test(char)) {
|
|
59
|
+
boundary = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (boundary && /\s/.test(char))
|
|
63
|
+
continue;
|
|
64
|
+
if (boundary && command.slice(index, index + 2).toLowerCase() === 'ec'
|
|
65
|
+
&& (index + 2 === command.length || /\s|[;&|`()<>]/.test(command[index + 2])))
|
|
66
|
+
return true;
|
|
67
|
+
boundary = false;
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
14
71
|
function trimShellSpacing(value) {
|
|
15
72
|
return value.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
16
73
|
}
|
|
@@ -83,6 +140,106 @@ function isLiteralArgBoundary(value) {
|
|
|
83
140
|
function isShellParameterExpansionStart(value) {
|
|
84
141
|
return value === '(' || value === '{' || !!value && /[A-Za-z0-9_@*#?$!\-]/.test(value);
|
|
85
142
|
}
|
|
143
|
+
function parseLiteralPowerShellCommand(command) {
|
|
144
|
+
const input = trimShellSpacing(command);
|
|
145
|
+
if (!input)
|
|
146
|
+
return { ok: false, issue: 'empty-command' };
|
|
147
|
+
if (input.includes('\0'))
|
|
148
|
+
return { ok: false, issue: 'invalid-control-char' };
|
|
149
|
+
const argv = [];
|
|
150
|
+
let token = '';
|
|
151
|
+
let tokenStarted = false;
|
|
152
|
+
let quote = null;
|
|
153
|
+
const finishToken = () => {
|
|
154
|
+
if (!tokenStarted)
|
|
155
|
+
return;
|
|
156
|
+
argv.push(token);
|
|
157
|
+
token = '';
|
|
158
|
+
tokenStarted = false;
|
|
159
|
+
};
|
|
160
|
+
for (let index = 0; index < input.length; index++) {
|
|
161
|
+
const char = input[index];
|
|
162
|
+
if (quote === 'single') {
|
|
163
|
+
if (char === "'" && input[index + 1] === "'") {
|
|
164
|
+
token += "'";
|
|
165
|
+
index++;
|
|
166
|
+
}
|
|
167
|
+
else if (char === "'") {
|
|
168
|
+
if (!isLiteralArgBoundary(input[index + 1]))
|
|
169
|
+
return { ok: false, issue: 'invalid-quote' };
|
|
170
|
+
quote = null;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
token += char;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (quote === 'double') {
|
|
178
|
+
if (char === '"') {
|
|
179
|
+
if (!isLiteralArgBoundary(input[index + 1]))
|
|
180
|
+
return { ok: false, issue: 'invalid-quote' };
|
|
181
|
+
quote = null;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (char === '`') {
|
|
185
|
+
const next = input[++index];
|
|
186
|
+
if (next === undefined)
|
|
187
|
+
return { ok: false, issue: 'invalid-quote' };
|
|
188
|
+
if (next === '\r' || next === '\n')
|
|
189
|
+
return { ok: false, issue: 'structural-newline' };
|
|
190
|
+
token += next;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (char === '$' && isShellParameterExpansionStart(input[index + 1])) {
|
|
194
|
+
return { ok: false, issue: 'unsafe-expansion' };
|
|
195
|
+
}
|
|
196
|
+
token += char;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (char === ' ' || char === '\t') {
|
|
200
|
+
finishToken();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (char === '\r' || char === '\n')
|
|
204
|
+
return { ok: false, issue: 'structural-newline' };
|
|
205
|
+
if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
|
|
206
|
+
return { ok: false, issue: 'invalid-control-char' };
|
|
207
|
+
}
|
|
208
|
+
if (char === "'") {
|
|
209
|
+
tokenStarted = true;
|
|
210
|
+
quote = 'single';
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (char === '"') {
|
|
214
|
+
tokenStarted = true;
|
|
215
|
+
quote = 'double';
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (char === '`') {
|
|
219
|
+
const next = input[++index];
|
|
220
|
+
if (next === undefined)
|
|
221
|
+
return { ok: false, issue: 'invalid-quote' };
|
|
222
|
+
if (next === '\r' || next === '\n')
|
|
223
|
+
return { ok: false, issue: 'structural-newline' };
|
|
224
|
+
tokenStarted = true;
|
|
225
|
+
token += next;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const isWordInitialMeta = (char === '#' || char === '@') && !tokenStarted;
|
|
229
|
+
const isAlwaysMeta = POWERSHELL_UNQUOTED_META.has(char) && char !== '#' && char !== '@';
|
|
230
|
+
if (isAlwaysMeta || isWordInitialMeta || (char === '~' && !tokenStarted)) {
|
|
231
|
+
return { ok: false, issue: 'shell-composition' };
|
|
232
|
+
}
|
|
233
|
+
tokenStarted = true;
|
|
234
|
+
token += char;
|
|
235
|
+
}
|
|
236
|
+
if (quote)
|
|
237
|
+
return { ok: false, issue: 'invalid-quote' };
|
|
238
|
+
finishToken();
|
|
239
|
+
return argv.length > 0
|
|
240
|
+
? { ok: true, argv }
|
|
241
|
+
: { ok: false, issue: 'empty-command' };
|
|
242
|
+
}
|
|
86
243
|
function doubleQuotedIssueAt(input, index) {
|
|
87
244
|
const char = input[index];
|
|
88
245
|
if (char === '`' || (char === '$' && isShellParameterExpansionStart(input[index + 1]))) {
|
|
@@ -102,6 +259,9 @@ function doubleQuotedIssueAt(input, index) {
|
|
|
102
259
|
* Anything outside this subset is never granted the privileged EC path.
|
|
103
260
|
*/
|
|
104
261
|
export function parseLiteralShellCommand(command, options = {}) {
|
|
262
|
+
if (options.dialect === 'powershell') {
|
|
263
|
+
return parseLiteralPowerShellCommand(command);
|
|
264
|
+
}
|
|
105
265
|
const input = trimShellSpacing(options.allowManagedTmpDir
|
|
106
266
|
? substituteManagedTmpDirToken(command)
|
|
107
267
|
: command);
|
|
@@ -209,15 +369,21 @@ export function parseLiteralShellArgv(command, options = {}) {
|
|
|
209
369
|
const result = parseLiteralShellCommand(command, options);
|
|
210
370
|
return result.ok ? result.argv : null;
|
|
211
371
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
* wrapper. The wrapper is accepted only in its exact three-argv form and the
|
|
215
|
-
* caller must parse the returned string with the literal shell parser again.
|
|
216
|
-
*/
|
|
217
|
-
export function unwrapCodexShellCommandArgv(argv) {
|
|
218
|
-
if (argv.length !== 3 || argv[1] !== '-lc')
|
|
372
|
+
export function resolveCodexShellCarrierArgv(argv) {
|
|
373
|
+
if (argv.length !== 3)
|
|
219
374
|
return undefined;
|
|
220
|
-
|
|
375
|
+
if (argv[1] === '-lc' && CODEX_POSIX_SHELL_EXECUTABLES.has(argv[0])) {
|
|
376
|
+
return { command: argv[2], dialect: 'posix' };
|
|
377
|
+
}
|
|
378
|
+
const powershellExecutable = argv[0].toLowerCase();
|
|
379
|
+
const isPowerShell = CODEX_POWERSHELL_EXECUTABLES.has(powershellExecutable)
|
|
380
|
+
|| CODEX_POWERSHELL_INSTALL_PATH_RE.test(argv[0]);
|
|
381
|
+
return isPowerShell && argv[1].toLowerCase() === '-command'
|
|
382
|
+
? { command: argv[2], dialect: 'powershell' }
|
|
383
|
+
: undefined;
|
|
384
|
+
}
|
|
385
|
+
export function unwrapCodexShellCommandArgv(argv) {
|
|
386
|
+
return resolveCodexShellCarrierArgv(argv)?.command;
|
|
221
387
|
}
|
|
222
388
|
/**
|
|
223
389
|
* Parse either a bare literal command or the exact shell carrier emitted by
|
|
@@ -228,8 +394,10 @@ export function parseCodexShellCommandArgv(command, options = {}) {
|
|
|
228
394
|
const outer = parseLiteralShellArgv(command, options);
|
|
229
395
|
if (!outer)
|
|
230
396
|
return null;
|
|
231
|
-
const
|
|
232
|
-
return
|
|
397
|
+
const carrier = resolveCodexShellCarrierArgv(outer);
|
|
398
|
+
return carrier === undefined
|
|
399
|
+
? outer
|
|
400
|
+
: parseLiteralShellArgv(carrier.command, { ...options, dialect: carrier.dialect });
|
|
233
401
|
}
|
|
234
402
|
/**
|
|
235
403
|
* Resolve a Codex command input while preserving the raw `commandArgv` form
|
|
@@ -239,10 +407,13 @@ export function parseCodexShellCommandArgv(command, options = {}) {
|
|
|
239
407
|
export function parseCodexToolCommandArgv(input) {
|
|
240
408
|
if (Array.isArray(input.commandArgv) && input.commandArgv.every(value => typeof value === 'string')) {
|
|
241
409
|
const outer = input.commandArgv;
|
|
242
|
-
const
|
|
243
|
-
return
|
|
410
|
+
const carrier = resolveCodexShellCarrierArgv(outer);
|
|
411
|
+
return carrier === undefined
|
|
244
412
|
? outer
|
|
245
|
-
: parseLiteralShellArgv(
|
|
413
|
+
: parseLiteralShellArgv(carrier.command, {
|
|
414
|
+
allowManagedTmpDir: true,
|
|
415
|
+
dialect: carrier.dialect,
|
|
416
|
+
});
|
|
246
417
|
}
|
|
247
418
|
const command = typeof input.command === 'string' ? input.command : '';
|
|
248
419
|
return parseCodexShellCommandArgv(command, { allowManagedTmpDir: true });
|
|
@@ -924,28 +1095,36 @@ export function parseBoundedOutputShellCommand(command) {
|
|
|
924
1095
|
* policy must not interpret EC subcommands or arguments: a single literal
|
|
925
1096
|
* invocation may leave the sandbox, while shell composition may not.
|
|
926
1097
|
*/
|
|
927
|
-
export function classifyEvolcoreShellCommand(command) {
|
|
928
|
-
const parseOptions = { allowManagedTmpDir: true };
|
|
1098
|
+
export function classifyEvolcoreShellCommand(command, options = {}) {
|
|
1099
|
+
const parseOptions = { allowManagedTmpDir: true, ...options };
|
|
929
1100
|
const literal = parseLiteralShellCommand(command, parseOptions);
|
|
930
1101
|
if (literal.ok) {
|
|
931
1102
|
if (literal.argv[0] === 'ec')
|
|
932
1103
|
return { kind: 'literal' };
|
|
933
|
-
const
|
|
934
|
-
if (
|
|
935
|
-
const nested = parseLiteralShellCommand(
|
|
1104
|
+
const carrier = resolveCodexShellCarrierArgv(literal.argv);
|
|
1105
|
+
if (carrier !== undefined) {
|
|
1106
|
+
const nested = parseLiteralShellCommand(carrier.command, {
|
|
1107
|
+
...parseOptions,
|
|
1108
|
+
dialect: carrier.dialect,
|
|
1109
|
+
});
|
|
936
1110
|
if (nested.ok && nested.argv[0] === 'ec')
|
|
937
1111
|
return { kind: 'literal' };
|
|
938
|
-
if (!nested.ok &&
|
|
1112
|
+
if (!nested.ok && hasEcCommandPrefix(carrier.command)) {
|
|
939
1113
|
return { kind: 'composite', issue: nested.issue };
|
|
940
1114
|
}
|
|
941
1115
|
}
|
|
942
1116
|
return { kind: 'none' };
|
|
943
1117
|
}
|
|
944
|
-
const boundedOutput =
|
|
1118
|
+
const boundedOutput = options.dialect === 'powershell'
|
|
1119
|
+
? null
|
|
1120
|
+
: parseBoundedOutputShellCommand(command);
|
|
945
1121
|
if (boundedOutput && boundedOutput.argv[0] === 'ec') {
|
|
946
1122
|
return { kind: 'bounded-output', command: boundedOutput };
|
|
947
1123
|
}
|
|
948
|
-
|
|
1124
|
+
// Only a command-boundary prefix is enough to preserve a useful syntax
|
|
1125
|
+
// error for a malformed direct EC invocation. Never scan for EC tokens in
|
|
1126
|
+
// arbitrary raw text after parsing fails.
|
|
1127
|
+
return hasEcCommandPrefix(command)
|
|
949
1128
|
? { kind: 'composite', issue: literal.issue }
|
|
950
1129
|
: { kind: 'none' };
|
|
951
1130
|
}
|