work-ally 0.2.0-alpha.1
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/AGENTS.md +110 -0
- package/DASHBOARD.md +160 -0
- package/PRODUCT.md +113 -0
- package/README.md +403 -0
- package/ally.sh +171 -0
- package/bridge/src/approval-rules.ts +360 -0
- package/bridge/src/channel-delivery.ts +207 -0
- package/bridge/src/channel-types.ts +22 -0
- package/bridge/src/channels/fake/adapter.ts +31 -0
- package/bridge/src/channels/feishu/adapter.ts +411 -0
- package/bridge/src/channels/feishu/approvals.ts +6 -0
- package/bridge/src/channels/feishu/formatter.ts +276 -0
- package/bridge/src/channels/feishu/normalize.ts +368 -0
- package/bridge/src/codex-config.ts +52 -0
- package/bridge/src/config.ts +240 -0
- package/bridge/src/fake-runtime-client.ts +505 -0
- package/bridge/src/handoff-service.ts +494 -0
- package/bridge/src/logger.ts +194 -0
- package/bridge/src/memory-digest.ts +186 -0
- package/bridge/src/receiver-approval-autonomy.ts +158 -0
- package/bridge/src/receiver-control-core.ts +140 -0
- package/bridge/src/receiver-control-work-session.ts +218 -0
- package/bridge/src/receiver-control.ts +83 -0
- package/bridge/src/receiver-delivery.ts +136 -0
- package/bridge/src/receiver-helpers.ts +96 -0
- package/bridge/src/receiver-human-gate.ts +333 -0
- package/bridge/src/receiver-inbound-preflight.ts +162 -0
- package/bridge/src/receiver-recovery.ts +236 -0
- package/bridge/src/receiver-runtime-callbacks.ts +367 -0
- package/bridge/src/receiver-runtime-policy.ts +132 -0
- package/bridge/src/receiver-runtime-state.ts +124 -0
- package/bridge/src/receiver-support-actions.ts +189 -0
- package/bridge/src/receiver-thread-start.ts +57 -0
- package/bridge/src/receiver-turn-coordination.ts +94 -0
- package/bridge/src/receiver-turn-execution.ts +257 -0
- package/bridge/src/receiver-turn-failure.ts +143 -0
- package/bridge/src/receiver-turn-result.ts +185 -0
- package/bridge/src/receiver-turn-steer.ts +70 -0
- package/bridge/src/receiver-work-session.ts +76 -0
- package/bridge/src/receiver.ts +329 -0
- package/bridge/src/router.ts +62 -0
- package/bridge/src/runtime-client-agent-messages.ts +150 -0
- package/bridge/src/runtime-client-message-dispatch.ts +176 -0
- package/bridge/src/runtime-client-protocol.ts +411 -0
- package/bridge/src/runtime-client-request-ops.ts +56 -0
- package/bridge/src/runtime-client-run-turn.ts +158 -0
- package/bridge/src/runtime-client-thread-ops.ts +270 -0
- package/bridge/src/runtime-client-transport.ts +309 -0
- package/bridge/src/runtime-client-turn-poll.ts +224 -0
- package/bridge/src/runtime-client-turn-read.ts +185 -0
- package/bridge/src/runtime-client-turn-state.ts +105 -0
- package/bridge/src/runtime-client.ts +344 -0
- package/bridge/src/runtime-user-input.ts +403 -0
- package/bridge/src/scheduler.ts +239 -0
- package/bridge/src/server-handoff-command.ts +364 -0
- package/bridge/src/server-main.ts +80 -0
- package/bridge/src/server-routine-command.ts +60 -0
- package/bridge/src/server-routine-execution.ts +222 -0
- package/bridge/src/server-runtime-app-support.ts +107 -0
- package/bridge/src/server-runtime-app.ts +238 -0
- package/bridge/src/server-thread-sync-command.ts +63 -0
- package/bridge/src/server.ts +17 -0
- package/bridge/src/session-store-delivery.ts +220 -0
- package/bridge/src/session-store-human-gate.ts +380 -0
- package/bridge/src/session-store-inbound-acceptance.ts +66 -0
- package/bridge/src/session-store-meta.ts +134 -0
- package/bridge/src/session-store-turn-ledger.ts +272 -0
- package/bridge/src/session-store.ts +380 -0
- package/bridge/src/system-notify.ts +220 -0
- package/bridge/src/thread-sync.ts +200 -0
- package/bridge/src/translator.ts +494 -0
- package/bridge/src/types.ts +289 -0
- package/bridge/src/utils.ts +104 -0
- package/bridge/src/work-session-store.ts +471 -0
- package/docs/.gitkeep +0 -0
- package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
- package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
- package/docs/completed/README.md +21 -0
- package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
- package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
- package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
- package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
- package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
- package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
- package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
- package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
- package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
- package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
- package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
- package/docs/developer-workflow.md +49 -0
- package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
- package/docs/implementation/test-coverage-map.md +363 -0
- package/docs/implementation/work-ally-implementation-guide.md +790 -0
- package/docs/issues/README.md +10 -0
- package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
- package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
- package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
- package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
- package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
- package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
- package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
- package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
- package/docs/manual-acceptance.md +127 -0
- package/docs/ops-runbook.md +44 -0
- package/docs/planning/FEATURE-memory-system.md +748 -0
- package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
- package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
- package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
- package/docs/planning/SPEC-assistant-rename.md +610 -0
- package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
- package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
- package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
- package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
- package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
- package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
- package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
- package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
- package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
- package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
- package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
- package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
- package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
- package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
- package/docs/planning/SPEC-stable-archive-contract.md +456 -0
- package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
- package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
- package/docs/planning/ally-next.md +1278 -0
- package/docs/planning/assistant-workbench-spec.md +725 -0
- package/docs/planning/product-workbench.md +283 -0
- package/docs/product-onboarding.md +227 -0
- package/docs/product-spec-standard.md +528 -0
- package/docs/troubleshooting.md +45 -0
- package/docs/user-quickstart.md +46 -0
- package/internal/dispatch.sh +95 -0
- package/internal/lib/common.sh +1450 -0
- package/internal/modules/assistant/manage.sh +1312 -0
- package/internal/modules/bootstrap/setup.sh +144 -0
- package/internal/modules/config/init-env.sh +10 -0
- package/internal/modules/global/manage.sh +154 -0
- package/internal/modules/handoff/manage.sh +54 -0
- package/internal/modules/mcp/manage.sh +83 -0
- package/internal/modules/ops/logs.sh +76 -0
- package/internal/modules/routines/manage.sh +55 -0
- package/internal/modules/runtime/assistant-autosave.sh +26 -0
- package/internal/modules/runtime/restart.sh +6 -0
- package/internal/modules/runtime/start.sh +283 -0
- package/internal/modules/runtime/status.sh +194 -0
- package/internal/modules/runtime/stop.sh +55 -0
- package/internal/modules/runtime/supervisor.sh +216 -0
- package/internal/modules/runtime/update.sh +26 -0
- package/package.json +41 -0
- package/runtime/config/.gitkeep +0 -0
- package/runtime/host/.gitkeep +0 -0
- package/runtime/host/healthcheck-codex-app-server.ts +22 -0
- package/runtime/host/ping-pong-codex-app-server.ts +66 -0
- package/runtime/host/probe-codex-app-server.ts +115 -0
- package/skills/archive-reader/SKILL.md +9 -0
- package/skills/feishu-production-debug/SKILL.md +37 -0
- package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
- package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
- package/skills/issue-to-spec-triage/SKILL.md +44 -0
- package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
- package/skills/memory-digest/SKILL.md +9 -0
- package/skills/post-implementation-closure/SKILL.md +39 -0
- package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
- package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
- package/skills/product-spec/SKILL.md +244 -0
- package/templates/env.example +5 -0
- package/templates/routines/nightly-memory-digest.yaml +10 -0
- package/templates/workspace/AGENTS.md +26 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { logWarn } from './logger.ts';
|
|
5
|
+
|
|
6
|
+
export type PrefixRuleDecision = 'allow' | 'prompt' | 'forbidden';
|
|
7
|
+
export type CommandRuleSource = 'global' | 'assistant';
|
|
8
|
+
export type CommandMatchForm = 'raw' | 'unwrapped';
|
|
9
|
+
|
|
10
|
+
export interface CommandApprovalRule {
|
|
11
|
+
source: CommandRuleSource;
|
|
12
|
+
filePath: string;
|
|
13
|
+
lineNumber: number;
|
|
14
|
+
pattern: string[];
|
|
15
|
+
decision: PrefixRuleDecision;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CommandApprovalRuleDirSummary {
|
|
19
|
+
source: CommandRuleSource;
|
|
20
|
+
dirPath: string;
|
|
21
|
+
exists: boolean;
|
|
22
|
+
fileCount: number;
|
|
23
|
+
ruleCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CommandApprovalRuleset {
|
|
27
|
+
rules: CommandApprovalRule[];
|
|
28
|
+
global: CommandApprovalRuleDirSummary;
|
|
29
|
+
assistant: CommandApprovalRuleDirSummary | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CommandApprovalRuleMatch {
|
|
33
|
+
rule: CommandApprovalRule;
|
|
34
|
+
commandForm: CommandMatchForm;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LoadCommandApprovalRulesOptions {
|
|
38
|
+
assistantCodexHome?: string | null;
|
|
39
|
+
globalCodexHome?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PREFIX_RULE_RE = /^prefix_rule\s*\(\s*pattern\s*=\s*(\[[\s\S]*\])\s*,\s*decision\s*=\s*"([a-z]+)"\s*\)\s*$/i;
|
|
43
|
+
|
|
44
|
+
function resolveGlobalCodexHome(explicit?: string | null): string {
|
|
45
|
+
const normalized = String(explicit || '').trim();
|
|
46
|
+
if (normalized) {
|
|
47
|
+
return path.resolve(normalized);
|
|
48
|
+
}
|
|
49
|
+
return path.join(os.homedir(), '.codex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeDirSummary(source: CommandRuleSource, dirPath: string): CommandApprovalRuleDirSummary {
|
|
53
|
+
return {
|
|
54
|
+
source,
|
|
55
|
+
dirPath,
|
|
56
|
+
exists: false,
|
|
57
|
+
fileCount: 0,
|
|
58
|
+
ruleCount: 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function emptyCommandApprovalRuleset(options: LoadCommandApprovalRulesOptions = {}): CommandApprovalRuleset {
|
|
63
|
+
const globalDir = path.join(resolveGlobalCodexHome(options.globalCodexHome), 'rules');
|
|
64
|
+
const assistantCodexHome = String(options.assistantCodexHome || '').trim();
|
|
65
|
+
const assistantDir = assistantCodexHome ? path.join(path.resolve(assistantCodexHome), 'rules') : null;
|
|
66
|
+
return {
|
|
67
|
+
rules: [],
|
|
68
|
+
global: makeDirSummary('global', globalDir),
|
|
69
|
+
assistant: assistantDir ? makeDirSummary('assistant', assistantDir) : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parsePrefixRuleLine(
|
|
74
|
+
line: string,
|
|
75
|
+
filePath: string,
|
|
76
|
+
lineNumber: number,
|
|
77
|
+
source: CommandRuleSource,
|
|
78
|
+
): CommandApprovalRule | null {
|
|
79
|
+
const trimmed = line.trim();
|
|
80
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const match = trimmed.match(PREFIX_RULE_RE);
|
|
85
|
+
if (!match) {
|
|
86
|
+
logWarn('approval_rules', 'line_ignored', {
|
|
87
|
+
source,
|
|
88
|
+
filePath,
|
|
89
|
+
lineNumber,
|
|
90
|
+
reason: 'unsupported syntax',
|
|
91
|
+
});
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const decision = String(match[2] || '').trim().toLowerCase();
|
|
96
|
+
if (decision !== 'allow' && decision !== 'prompt' && decision !== 'forbidden') {
|
|
97
|
+
logWarn('approval_rules', 'line_ignored', {
|
|
98
|
+
source,
|
|
99
|
+
filePath,
|
|
100
|
+
lineNumber,
|
|
101
|
+
reason: 'unsupported decision',
|
|
102
|
+
decision,
|
|
103
|
+
});
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const pattern = JSON.parse(match[1]);
|
|
109
|
+
if (!Array.isArray(pattern) || pattern.length === 0 || pattern.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
|
|
110
|
+
throw new Error('pattern must be a non-empty string array');
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
source,
|
|
114
|
+
filePath,
|
|
115
|
+
lineNumber,
|
|
116
|
+
pattern,
|
|
117
|
+
decision,
|
|
118
|
+
};
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logWarn('approval_rules', 'line_ignored', {
|
|
121
|
+
source,
|
|
122
|
+
filePath,
|
|
123
|
+
lineNumber,
|
|
124
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
125
|
+
});
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadRulesFromDir(summary: CommandApprovalRuleDirSummary): CommandApprovalRule[] {
|
|
131
|
+
if (!fs.existsSync(summary.dirPath)) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
summary.exists = true;
|
|
135
|
+
const entries = fs.readdirSync(summary.dirPath, { withFileTypes: true });
|
|
136
|
+
const files = entries
|
|
137
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.rules'))
|
|
138
|
+
.map((entry) => entry.name)
|
|
139
|
+
.sort((left, right) => left.localeCompare(right));
|
|
140
|
+
summary.fileCount = files.length;
|
|
141
|
+
|
|
142
|
+
const rules: CommandApprovalRule[] = [];
|
|
143
|
+
for (const name of files) {
|
|
144
|
+
const filePath = path.join(summary.dirPath, name);
|
|
145
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
146
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
147
|
+
const rule = parsePrefixRuleLine(lines[index], filePath, index + 1, summary.source);
|
|
148
|
+
if (rule) {
|
|
149
|
+
rules.push(rule);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
summary.ruleCount = rules.length;
|
|
154
|
+
return rules;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loadCommandApprovalRules(options: LoadCommandApprovalRulesOptions = {}): CommandApprovalRuleset {
|
|
158
|
+
const ruleset = emptyCommandApprovalRuleset(options);
|
|
159
|
+
ruleset.rules = [
|
|
160
|
+
...loadRulesFromDir(ruleset.global),
|
|
161
|
+
...(ruleset.assistant ? loadRulesFromDir(ruleset.assistant) : []),
|
|
162
|
+
];
|
|
163
|
+
return ruleset;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function tokenizeShellCommand(command: string): string[] | null {
|
|
167
|
+
const tokens: string[] = [];
|
|
168
|
+
let current = '';
|
|
169
|
+
let quote: 'single' | 'double' | null = null;
|
|
170
|
+
let escaping = false;
|
|
171
|
+
|
|
172
|
+
const pushCurrent = () => {
|
|
173
|
+
if (!current) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
tokens.push(current);
|
|
177
|
+
current = '';
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
181
|
+
const char = command[index];
|
|
182
|
+
if (escaping) {
|
|
183
|
+
current += char;
|
|
184
|
+
escaping = false;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (quote === 'single') {
|
|
189
|
+
if (char === "'") {
|
|
190
|
+
quote = null;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
current += char;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (quote === 'double') {
|
|
198
|
+
if (char === '"') {
|
|
199
|
+
quote = null;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (char === '\\') {
|
|
203
|
+
const next = command[index + 1];
|
|
204
|
+
if (next === undefined) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
current += next;
|
|
208
|
+
index += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
current += char;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (/\s/.test(char)) {
|
|
216
|
+
pushCurrent();
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (char === '\\') {
|
|
220
|
+
escaping = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (char === "'") {
|
|
224
|
+
quote = 'single';
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (char === '"') {
|
|
228
|
+
quote = 'double';
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
current += char;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (escaping || quote) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
pushCurrent();
|
|
238
|
+
return tokens;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function unwrapShellApprovalCommand(command: string): string {
|
|
242
|
+
const trimmed = String(command || '').trim();
|
|
243
|
+
const match = trimmed.match(/^(?:\/bin\/)?(?:zsh|bash|sh)\s+-lc\s+(['"])([\s\S]*)\1$/i);
|
|
244
|
+
return (match?.[2] || trimmed).trim();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function hasUnsafeShellSyntax(command: string): boolean {
|
|
248
|
+
if (!command) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
return /&&|\|\||[|;<>`()]|<<|\$\(/.test(command);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function matchesPatternPrefix(pattern: string[], argv: string[]): boolean {
|
|
255
|
+
if (pattern.length > argv.length) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return pattern.every((entry, index) => argv[index] === entry);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function matchRuleAgainstCommand(rule: CommandApprovalRule, command: string): CommandMatchForm | null {
|
|
262
|
+
const rawArgv = tokenizeShellCommand(command);
|
|
263
|
+
if (rawArgv && matchesPatternPrefix(rule.pattern, rawArgv)) {
|
|
264
|
+
return 'raw';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const unwrapped = unwrapShellApprovalCommand(command);
|
|
268
|
+
if (unwrapped === command || hasUnsafeShellSyntax(unwrapped)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const innerArgv = tokenizeShellCommand(unwrapped);
|
|
272
|
+
if (innerArgv && matchesPatternPrefix(rule.pattern, innerArgv)) {
|
|
273
|
+
return 'unwrapped';
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sourcePriority(source: CommandRuleSource): number {
|
|
279
|
+
return source === 'assistant' ? 2 : 1;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function decisionPriority(decision: PrefixRuleDecision): number {
|
|
283
|
+
if (decision === 'forbidden') {
|
|
284
|
+
return 3;
|
|
285
|
+
}
|
|
286
|
+
if (decision === 'prompt') {
|
|
287
|
+
return 2;
|
|
288
|
+
}
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function findMatchingCommandApprovalRule(
|
|
293
|
+
ruleset: CommandApprovalRuleset,
|
|
294
|
+
command: string,
|
|
295
|
+
): CommandApprovalRuleMatch | null {
|
|
296
|
+
const trimmed = String(command || '').trim();
|
|
297
|
+
if (!trimmed) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const matches: CommandApprovalRuleMatch[] = [];
|
|
302
|
+
for (const rule of ruleset.rules) {
|
|
303
|
+
const commandForm = matchRuleAgainstCommand(rule, trimmed);
|
|
304
|
+
if (commandForm) {
|
|
305
|
+
matches.push({ rule, commandForm });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (matches.length === 0) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
matches.sort((left, right) => {
|
|
314
|
+
const sourceDelta = sourcePriority(right.rule.source) - sourcePriority(left.rule.source);
|
|
315
|
+
if (sourceDelta !== 0) {
|
|
316
|
+
return sourceDelta;
|
|
317
|
+
}
|
|
318
|
+
const decisionDelta = decisionPriority(right.rule.decision) - decisionPriority(left.rule.decision);
|
|
319
|
+
if (decisionDelta !== 0) {
|
|
320
|
+
return decisionDelta;
|
|
321
|
+
}
|
|
322
|
+
const lengthDelta = right.rule.pattern.length - left.rule.pattern.length;
|
|
323
|
+
if (lengthDelta !== 0) {
|
|
324
|
+
return lengthDelta;
|
|
325
|
+
}
|
|
326
|
+
const pathDelta = left.rule.filePath.localeCompare(right.rule.filePath);
|
|
327
|
+
if (pathDelta !== 0) {
|
|
328
|
+
return pathDelta;
|
|
329
|
+
}
|
|
330
|
+
return left.rule.lineNumber - right.rule.lineNumber;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
return matches[0] || null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function commandApprovalRuleLabel(match: CommandApprovalRuleMatch): string {
|
|
337
|
+
const fileName = path.basename(match.rule.filePath);
|
|
338
|
+
return `${match.rule.source}:${fileName}:${match.rule.lineNumber}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function formatDirSummary(summary: CommandApprovalRuleDirSummary, label: string): string {
|
|
342
|
+
const state = summary.exists
|
|
343
|
+
? `${summary.fileCount} files / ${summary.ruleCount} rules`
|
|
344
|
+
: 'missing';
|
|
345
|
+
return `- ${label}: ${summary.dirPath} (${state})`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function formatCommandApprovalRulesStatusLines(ruleset: CommandApprovalRuleset): string[] {
|
|
349
|
+
const lines = [
|
|
350
|
+
'- load mode: startup snapshot; restart assistant to refresh global rule changes',
|
|
351
|
+
`- loaded rules: ${ruleset.rules.length}`,
|
|
352
|
+
formatDirSummary(ruleset.global, 'global rules'),
|
|
353
|
+
];
|
|
354
|
+
if (ruleset.assistant) {
|
|
355
|
+
lines.push(formatDirSummary(ruleset.assistant, 'assistant-local rules'));
|
|
356
|
+
} else {
|
|
357
|
+
lines.push('- assistant-local rules: unavailable (assistant codex home not configured)');
|
|
358
|
+
}
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { logWarn } from './logger.ts';
|
|
3
|
+
import { nowIso, readJsonFile, writeJsonFile } from './utils.ts';
|
|
4
|
+
|
|
5
|
+
export type ChannelDeliveryIssueKind = 'permission_denied' | 'app_not_ready' | 'rate_limited';
|
|
6
|
+
|
|
7
|
+
export interface ChannelDeliveryIssue {
|
|
8
|
+
channel: 'feishu';
|
|
9
|
+
kind: ChannelDeliveryIssueKind;
|
|
10
|
+
code?: string | null;
|
|
11
|
+
message: string;
|
|
12
|
+
guidance: string;
|
|
13
|
+
retryable: false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ChannelDeliveryError extends Error {
|
|
17
|
+
readonly issue: ChannelDeliveryIssue;
|
|
18
|
+
readonly cause?: unknown;
|
|
19
|
+
|
|
20
|
+
constructor(issue: ChannelDeliveryIssue, cause?: unknown) {
|
|
21
|
+
super(issue.message);
|
|
22
|
+
this.name = 'ChannelDeliveryError';
|
|
23
|
+
this.issue = issue;
|
|
24
|
+
this.cause = cause;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stringifyError(error: unknown): string {
|
|
29
|
+
if (error instanceof Error) {
|
|
30
|
+
return error.stack || error.message;
|
|
31
|
+
}
|
|
32
|
+
return String(error);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function joinDefined(values: Array<string | null | undefined>): string {
|
|
36
|
+
return values
|
|
37
|
+
.map((value) => String(value || '').trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.join(' ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function feishuErrorCode(error: any): string | null {
|
|
43
|
+
const candidates = [
|
|
44
|
+
error?.response?.data?.code,
|
|
45
|
+
error?.code,
|
|
46
|
+
];
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
if (candidate === undefined || candidate === null || candidate === '') {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return String(candidate);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function feishuErrorText(error: any): string {
|
|
57
|
+
return joinDefined([
|
|
58
|
+
error?.response?.data?.msg,
|
|
59
|
+
error?.response?.data?.message,
|
|
60
|
+
error?.response?.data?.error?.message,
|
|
61
|
+
error?.message,
|
|
62
|
+
stringifyError(error),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function classifyFeishuDeliveryIssue(error: unknown): ChannelDeliveryIssue | null {
|
|
67
|
+
const code = feishuErrorCode(error);
|
|
68
|
+
const text = feishuErrorText(error);
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
code === '99991672'
|
|
72
|
+
|| /scope(?:s)? is required/i.test(text)
|
|
73
|
+
|| /应用尚未开通所需的应用身份权限/.test(text)
|
|
74
|
+
|| /im:message:send(?:_as_bot)?/i.test(text)
|
|
75
|
+
) {
|
|
76
|
+
return {
|
|
77
|
+
channel: 'feishu',
|
|
78
|
+
kind: 'permission_denied',
|
|
79
|
+
code,
|
|
80
|
+
retryable: false,
|
|
81
|
+
message: 'Feishu 当前没有发消息权限,bridge 会继续运行,但这条消息发不出去。',
|
|
82
|
+
guidance: '去飞书开放平台为当前应用开通发消息权限,并完成审核/发布后再重试。',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
/未发布|尚未发布|未上架|审核中|未审核/.test(text)
|
|
88
|
+
|| /not (?:released|published|approved)/i.test(text)
|
|
89
|
+
|| /under review/i.test(text)
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
channel: 'feishu',
|
|
93
|
+
kind: 'app_not_ready',
|
|
94
|
+
code,
|
|
95
|
+
retryable: false,
|
|
96
|
+
message: 'Feishu 应用还没有完成审核发布,bridge 会继续运行,但这条消息发不出去。',
|
|
97
|
+
guidance: '先让机器人完成审核/发布,再恢复消息投递。',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
code === '230020'
|
|
103
|
+
|| /chat rate limit/i.test(text)
|
|
104
|
+
|| /frequency limit/i.test(text)
|
|
105
|
+
) {
|
|
106
|
+
return {
|
|
107
|
+
channel: 'feishu',
|
|
108
|
+
kind: 'rate_limited',
|
|
109
|
+
code,
|
|
110
|
+
retryable: false,
|
|
111
|
+
message: 'Feishu 当前触发消息频控,bridge 会继续运行,但部分进度消息会被丢弃。',
|
|
112
|
+
guidance: '减少短时间内的系统/进度消息数量,避免把内部日志直接转发到会话里。',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function normalizeChannelDeliveryError(channel: string, error: unknown): Error {
|
|
120
|
+
if (error instanceof ChannelDeliveryError) {
|
|
121
|
+
return error;
|
|
122
|
+
}
|
|
123
|
+
if (channel === 'feishu') {
|
|
124
|
+
const issue = classifyFeishuDeliveryIssue(error);
|
|
125
|
+
if (issue) {
|
|
126
|
+
return new ChannelDeliveryError(issue, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (error instanceof Error) {
|
|
130
|
+
return error;
|
|
131
|
+
}
|
|
132
|
+
return new Error(String(error));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isNonFatalChannelDeliveryError(error: unknown): error is ChannelDeliveryError {
|
|
136
|
+
return error instanceof ChannelDeliveryError && error.issue.retryable === false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readBridgeHealthSnapshot(healthFile: string): Record<string, unknown> | null {
|
|
140
|
+
try {
|
|
141
|
+
const current = readJsonFile(healthFile, null);
|
|
142
|
+
if (!current || typeof current !== 'object') {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return current as Record<string, unknown>;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logWarn('channel_delivery', 'bridge_health_corrupted', {
|
|
148
|
+
healthFile,
|
|
149
|
+
error: error instanceof Error ? (error.stack || error.message) : String(error),
|
|
150
|
+
});
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function recordBridgeDeliveryIssue(runtimeDir: string, error: ChannelDeliveryError): void {
|
|
156
|
+
const healthFile = path.join(runtimeDir, 'bridge.health.json');
|
|
157
|
+
const current = readBridgeHealthSnapshot(healthFile);
|
|
158
|
+
if (!current || typeof current !== 'object') {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
writeJsonFile(healthFile, {
|
|
163
|
+
...current,
|
|
164
|
+
delivery_status: 'unavailable',
|
|
165
|
+
delivery_channel: error.issue.channel,
|
|
166
|
+
delivery_error_kind: error.issue.kind,
|
|
167
|
+
delivery_error_code: error.issue.code || null,
|
|
168
|
+
delivery_message: error.issue.message,
|
|
169
|
+
delivery_guidance: error.issue.guidance,
|
|
170
|
+
delivery_updated_at: nowIso(),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function clearBridgeDeliveryIssue(runtimeDir: string): void {
|
|
175
|
+
const healthFile = path.join(runtimeDir, 'bridge.health.json');
|
|
176
|
+
const current = readBridgeHealthSnapshot(healthFile);
|
|
177
|
+
if (!current || typeof current !== 'object') {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const next = { ...current } as Record<string, unknown>;
|
|
182
|
+
delete next.delivery_status;
|
|
183
|
+
delete next.delivery_channel;
|
|
184
|
+
delete next.delivery_error_kind;
|
|
185
|
+
delete next.delivery_error_code;
|
|
186
|
+
delete next.delivery_message;
|
|
187
|
+
delete next.delivery_guidance;
|
|
188
|
+
delete next.delivery_updated_at;
|
|
189
|
+
writeJsonFile(healthFile, next);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function describeChannelDeliveryIssue(error: ChannelDeliveryError): Record<string, unknown> {
|
|
193
|
+
return {
|
|
194
|
+
channel: error.issue.channel,
|
|
195
|
+
kind: error.issue.kind,
|
|
196
|
+
code: error.issue.code || null,
|
|
197
|
+
message: error.issue.message,
|
|
198
|
+
guidance: error.issue.guidance,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function logNonFatalChannelDelivery(component: string, event: string, error: ChannelDeliveryError, meta: Record<string, unknown> = {}): void {
|
|
203
|
+
logWarn(component, event, {
|
|
204
|
+
...meta,
|
|
205
|
+
...describeChannelDeliveryIssue(error),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ApprovalRequest, ConnectionLifecycleEvent, ConversationRef, InboundMessage, PendingUserInputRecord } from './types.ts';
|
|
2
|
+
|
|
3
|
+
export type OutboundEnvelope =
|
|
4
|
+
| { type: 'ack_received'; conversationRef: ConversationRef; messageId: string; emoji?: string }
|
|
5
|
+
| { type: 'status_reply'; conversationRef: ConversationRef; text: string }
|
|
6
|
+
| { type: 'progress_update'; conversationRef: ConversationRef; text: string }
|
|
7
|
+
| { type: 'approval_requested'; conversationRef: ConversationRef; approval: ApprovalRequest; text: string }
|
|
8
|
+
| { type: 'user_input_requested'; conversationRef: ConversationRef; request: PendingUserInputRecord; text: string }
|
|
9
|
+
| { type: 'final_reply'; conversationRef: ConversationRef; text: string }
|
|
10
|
+
| { type: 'error_reply'; conversationRef: ConversationRef; text: string };
|
|
11
|
+
|
|
12
|
+
export interface ChannelAdapter {
|
|
13
|
+
start(): Promise<void>;
|
|
14
|
+
stop(): Promise<void>;
|
|
15
|
+
send(outbound: OutboundEnvelope): Promise<void>;
|
|
16
|
+
onConnectionEvent?(listener: (event: ConnectionLifecycleEvent) => void): void;
|
|
17
|
+
offConnectionEvent?(listener: (event: ConnectionLifecycleEvent) => void): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InboundHandler {
|
|
21
|
+
handleInbound(message: InboundMessage): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ChannelAdapter, InboundHandler, OutboundEnvelope } from '../../channel-types.ts';
|
|
2
|
+
import type { InboundMessage } from '../../types.ts';
|
|
3
|
+
|
|
4
|
+
export class FakeChannelAdapter implements ChannelAdapter {
|
|
5
|
+
public readonly sent: OutboundEnvelope[] = [];
|
|
6
|
+
private started = false;
|
|
7
|
+
private readonly inboundHandler: InboundHandler;
|
|
8
|
+
|
|
9
|
+
constructor(inboundHandler: InboundHandler) {
|
|
10
|
+
this.inboundHandler = inboundHandler;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async start(): Promise<void> {
|
|
14
|
+
this.started = true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async stop(): Promise<void> {
|
|
18
|
+
this.started = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async send(outbound: OutboundEnvelope): Promise<void> {
|
|
22
|
+
this.sent.push(outbound);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async inject(message: InboundMessage): Promise<void> {
|
|
26
|
+
if (!this.started) {
|
|
27
|
+
throw new Error('fake channel is not started');
|
|
28
|
+
}
|
|
29
|
+
await this.inboundHandler.handleInbound(message);
|
|
30
|
+
}
|
|
31
|
+
}
|