@goodandready/dsh-goal 0.2.25 → 0.2.27
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/README.md +5 -0
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +183 -7
- package/lib/engine-sessions.js +78 -1
- package/lib/goal-engine-constants.js +7 -0
- package/lib/goal-engine.js +33 -29
- package/lib/index.js +4 -0
- package/lib/routes.js +218 -69
- package/lib/tool-guard.js +147 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -136,6 +136,11 @@ Handles `/goal` commands and subcommands:
|
|
|
136
136
|
* **Quick Launch Button**: Floating launcher for rapid goal formulation without typing slash commands.
|
|
137
137
|
* **Plugin Settings Card**: Schemastery-backed settings UI registered via `settings.plugin.item`.
|
|
138
138
|
|
|
139
|
+
### 5. Two-Phase Goal Execution: Plan Mode ⇄ Act Mode (#162)
|
|
140
|
+
* **Plan Mode (Read-Only Planning Phase)**: The agent explores the codebase, searches the web, and decomposes tasks into structured milestones and checklists. All file mutating tools (`write_to_file`, `replace_file_content`, mutating shell commands) are strictly blocked by `lib/tool-guard.js`.
|
|
141
|
+
* **Act Mode (Implementation Phase)**: The operator reviews and approves the plan directly via the banner or details modal with «Approve & Execute», unlocking mutating capabilities and resuming the agent.
|
|
142
|
+
* **REST API & Quick Launch**: Toggle modes on demand via `POST /dsh-goal/action` (`set_mode`, `approve_plan`), or select initial mode directly in the Quick Launch dialog.
|
|
143
|
+
|
|
139
144
|
---
|
|
140
145
|
|
|
141
146
|
## 📦 Installation
|
package/README.ru.md
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* 🤖 **Инструменты агента**: `goal_set_milestones`, `goal_update_progress` и `goal_finish` для самостоятельной работы ИИ.
|
|
54
54
|
* 🔄 **Перехват и замещение инструментов ядра DSH (*Добавлено в v0.1.10*)**: Бесшовная совместимость для моделей, вызывающих стандартные инструменты DSH (`update_goal`, `get_goal`, `create_goal`). Перехватывает действия (`complete`, `pause`, `resume`, `edit`, `blocked`), устраняет падения ядра (`complete and blocked require a direct human turn`) и синхронизирует состояние напрямую с GoalEngine.
|
|
55
55
|
* 🛡️ **Защитные лимиты**: настраиваемое ограничение `maxIterations` для предотвращения бесконечных циклов.
|
|
56
|
+
* 🛡️ **Двухфазный режим «Plan Mode ⇄ Act Mode» (*#162*)**: Разделение жизненного цикла цели на фазу проектирования и планирования (только чтение и формирование этапов через `goal_milestones`, блокировка записи файлов и мутирующих команд) и фазу реализации (`Act Mode`), активируемую по кнопке «Утвердить план и запустить» (Approve & Execute).
|
|
56
57
|
|
|
57
58
|
---
|
|
58
59
|
|
package/README.zh.md
CHANGED
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
* 🔄 **DSH 核心目标工具无缝拦截与兼容 (*v0.1.10 新增*)**: 为使用原生 DSH 目标控制指令的模型提供完美向下兼容,透明接管 `update_goal`、`get_goal`、`create_goal`。拦截并处理 `complete`、`pause`、`resume`、`edit`、`blocked` 等操作,彻底解决原生工具抛出的 `complete and blocked require a direct human turn` 权限崩溃错误,所有状态直接同步至 GoalEngine。
|
|
55
55
|
* 🛡️ **安全防护机制**:支持自定义最大迭代次数(`maxIterations`)以及智能卡顿检测(Smart Progress Guard)。
|
|
56
56
|
* 🔔 **Web Audio 提示音**:任务完成或失败时,通过 Web Audio API 播放舒缓的合成音效。
|
|
57
|
+
* 📋 **两阶段目标模式:Plan Mode ⇄ Act Mode (*#162*)**:分离规划设计与落地执行。只读规划阶段严格限制文件变更与危险命令,待操作者在横幅或弹窗点击“批准并执行 (Approve & Execute)”后切入执行阶段。
|
|
57
58
|
|
|
58
59
|
---
|
|
59
60
|
|
package/lib/client.js
CHANGED
|
@@ -730,6 +730,66 @@ window.__ModuleLoader__.load({
|
|
|
730
730
|
|
|
731
731
|
}
|
|
732
732
|
|
|
733
|
+
.dsh-goal-badge-plan {
|
|
734
|
+
|
|
735
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
736
|
+
|
|
737
|
+
color: var(--dsw-alias-brand-primary);
|
|
738
|
+
|
|
739
|
+
background: var(--dsw-alias-bg-hover);
|
|
740
|
+
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.dsh-goal-badge-act {
|
|
744
|
+
|
|
745
|
+
border-color: var(--dsw-alias-state-success-primary);
|
|
746
|
+
|
|
747
|
+
color: var(--dsw-alias-state-success-primary);
|
|
748
|
+
|
|
749
|
+
background: var(--dsw-alias-bg-hover);
|
|
750
|
+
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.dsh-goal-btn-approve {
|
|
754
|
+
|
|
755
|
+
background: var(--dsw-alias-state-success-primary) !important;
|
|
756
|
+
|
|
757
|
+
color: var(--dsw-alias-bg-layer-3) !important;
|
|
758
|
+
|
|
759
|
+
font-weight: 600 !important;
|
|
760
|
+
|
|
761
|
+
border: none !important;
|
|
762
|
+
|
|
763
|
+
cursor: pointer;
|
|
764
|
+
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.dsh-goal-btn-approve:hover {
|
|
768
|
+
|
|
769
|
+
opacity: 0.88;
|
|
770
|
+
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.dsh-goal-plan-banner {
|
|
774
|
+
|
|
775
|
+
display: flex;
|
|
776
|
+
|
|
777
|
+
align-items: center;
|
|
778
|
+
|
|
779
|
+
justify-content: space-between;
|
|
780
|
+
|
|
781
|
+
padding: 8px 12px;
|
|
782
|
+
|
|
783
|
+
margin-bottom: 12px;
|
|
784
|
+
|
|
785
|
+
border-radius: 6px;
|
|
786
|
+
|
|
787
|
+
background: var(--dsw-alias-bg-hover);
|
|
788
|
+
|
|
789
|
+
border: 1px solid var(--dsw-alias-brand-primary);
|
|
790
|
+
|
|
791
|
+
}
|
|
792
|
+
|
|
733
793
|
.dsh-goal-label {
|
|
734
794
|
|
|
735
795
|
font-weight: 600;
|
|
@@ -1855,6 +1915,7 @@ window.__ModuleLoader__.load({
|
|
|
1855
1915
|
const milestones = state.milestones || [];
|
|
1856
1916
|
|
|
1857
1917
|
const isCompleted = state.state === 'COMPLETED';
|
|
1918
|
+
const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
|
|
1858
1919
|
|
|
1859
1920
|
const [copied, setCopied] = useState(false);
|
|
1860
1921
|
|
|
@@ -1992,6 +2053,36 @@ window.__ModuleLoader__.load({
|
|
|
1992
2053
|
|
|
1993
2054
|
),
|
|
1994
2055
|
|
|
2056
|
+
isPlanMode && !isCompleted
|
|
2057
|
+
? React.createElement(
|
|
2058
|
+
'div',
|
|
2059
|
+
{ className: 'dsh-goal-plan-banner' },
|
|
2060
|
+
React.createElement(
|
|
2061
|
+
'div',
|
|
2062
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: 2 } },
|
|
2063
|
+
React.createElement(
|
|
2064
|
+
'div',
|
|
2065
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
2066
|
+
React.createElement('span', { className: 'dsh-goal-badge dsh-goal-badge-plan' }, t('planMode') || 'Plan Mode'),
|
|
2067
|
+
React.createElement('strong', { style: { fontSize: 13, color: 'var(--dsw-alias-brand-primary)' } }, t('planningPhase') || 'Planning Phase (Read-Only)')
|
|
2068
|
+
),
|
|
2069
|
+
React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('planPhaseNotice') || 'Agent is formulating the plan. File modifications are guarded until operator approval.')
|
|
2070
|
+
),
|
|
2071
|
+
React.createElement(
|
|
2072
|
+
'button',
|
|
2073
|
+
{
|
|
2074
|
+
type: 'button',
|
|
2075
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
2076
|
+
style: { padding: '6px 14px', fontSize: 12 },
|
|
2077
|
+
onClick: async () => {
|
|
2078
|
+
await onAction('approve_plan');
|
|
2079
|
+
},
|
|
2080
|
+
},
|
|
2081
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
2082
|
+
)
|
|
2083
|
+
)
|
|
2084
|
+
: null,
|
|
2085
|
+
|
|
1995
2086
|
!isCompleted
|
|
1996
2087
|
|
|
1997
2088
|
? React.createElement(
|
|
@@ -2561,6 +2652,20 @@ window.__ModuleLoader__.load({
|
|
|
2561
2652
|
|
|
2562
2653
|
: null,
|
|
2563
2654
|
|
|
2655
|
+
isPlanMode && !isCompleted
|
|
2656
|
+
? React.createElement(
|
|
2657
|
+
'button',
|
|
2658
|
+
{
|
|
2659
|
+
type: 'button',
|
|
2660
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
2661
|
+
onClick: async () => {
|
|
2662
|
+
await onAction('approve_plan');
|
|
2663
|
+
},
|
|
2664
|
+
},
|
|
2665
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
2666
|
+
)
|
|
2667
|
+
: null,
|
|
2668
|
+
|
|
2564
2669
|
React.createElement(
|
|
2565
2670
|
|
|
2566
2671
|
'button',
|
|
@@ -2590,6 +2695,7 @@ window.__ModuleLoader__.load({
|
|
|
2590
2695
|
function QuickLaunchModal({ onClose, onStart, t }) {
|
|
2591
2696
|
|
|
2592
2697
|
const [title, setTitle] = useState('');
|
|
2698
|
+
const [launchMode, setLaunchMode] = useState('act');
|
|
2593
2699
|
const [showTemplates, setShowTemplates] = useState(false);
|
|
2594
2700
|
const [backendTemplates, setBackendTemplates] = useState([]);
|
|
2595
2701
|
const [selectedTpl, setSelectedTpl] = useState(null);
|
|
@@ -2649,7 +2755,7 @@ window.__ModuleLoader__.load({
|
|
|
2649
2755
|
|
|
2650
2756
|
if (trimmed) {
|
|
2651
2757
|
|
|
2652
|
-
onStart(trimmed);
|
|
2758
|
+
onStart(trimmed, { mode: launchMode });
|
|
2653
2759
|
|
|
2654
2760
|
}
|
|
2655
2761
|
|
|
@@ -2727,6 +2833,34 @@ window.__ModuleLoader__.load({
|
|
|
2727
2833
|
|
|
2728
2834
|
),
|
|
2729
2835
|
|
|
2836
|
+
React.createElement(
|
|
2837
|
+
'div',
|
|
2838
|
+
{ style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 } },
|
|
2839
|
+
React.createElement('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, t('launchModeLabel') || 'Mode:'),
|
|
2840
|
+
React.createElement(
|
|
2841
|
+
'button',
|
|
2842
|
+
{
|
|
2843
|
+
type: 'button',
|
|
2844
|
+
className: `dsh-goal-chip-btn ${launchMode === 'act' ? 'active' : ''}`,
|
|
2845
|
+
style: launchMode === 'act' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-state-success-primary)', color: 'var(--dsw-alias-state-success-primary)' } : {},
|
|
2846
|
+
onClick: () => setLaunchMode('act'),
|
|
2847
|
+
title: t('actModeDesc') || 'Direct execution right away',
|
|
2848
|
+
},
|
|
2849
|
+
'⚡ ' + (t('actMode') || 'Act Mode')
|
|
2850
|
+
),
|
|
2851
|
+
React.createElement(
|
|
2852
|
+
'button',
|
|
2853
|
+
{
|
|
2854
|
+
type: 'button',
|
|
2855
|
+
className: `dsh-goal-chip-btn ${launchMode === 'plan' ? 'active' : ''}`,
|
|
2856
|
+
style: launchMode === 'plan' ? { fontWeight: 600, borderColor: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-brand-primary)' } : {},
|
|
2857
|
+
onClick: () => setLaunchMode('plan'),
|
|
2858
|
+
title: t('planModeDesc') || 'Plan first, review milestones, then execute',
|
|
2859
|
+
},
|
|
2860
|
+
'📋 ' + (t('planMode') || 'Plan Mode')
|
|
2861
|
+
)
|
|
2862
|
+
),
|
|
2863
|
+
|
|
2730
2864
|
React.createElement(
|
|
2731
2865
|
|
|
2732
2866
|
'div',
|
|
@@ -3504,9 +3638,9 @@ window.__ModuleLoader__.load({
|
|
|
3504
3638
|
|
|
3505
3639
|
onClose: () => setIsQuickLaunchOpen(false),
|
|
3506
3640
|
|
|
3507
|
-
onStart: async (title) => {
|
|
3641
|
+
onStart: async (title, opts) => {
|
|
3508
3642
|
|
|
3509
|
-
await handleAction('start', { title });
|
|
3643
|
+
await handleAction('start', { title, mode: opts?.mode });
|
|
3510
3644
|
|
|
3511
3645
|
setIsQuickLaunchOpen(false);
|
|
3512
3646
|
|
|
@@ -3526,6 +3660,8 @@ window.__ModuleLoader__.load({
|
|
|
3526
3660
|
|
|
3527
3661
|
const isCompleted = state.state === 'COMPLETED';
|
|
3528
3662
|
|
|
3663
|
+
const isPlanMode = state.mode === 'plan' || state.state === 'PLANNING';
|
|
3664
|
+
|
|
3529
3665
|
const formatElapsed = (totalSeconds) => {
|
|
3530
3666
|
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
|
|
3531
3667
|
if (s < 60) return `${s}s`;
|
|
@@ -3580,11 +3716,21 @@ window.__ModuleLoader__.load({
|
|
|
3580
3716
|
),
|
|
3581
3717
|
React.createElement(
|
|
3582
3718
|
'span',
|
|
3583
|
-
{ className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
|
|
3584
|
-
isCompleted ? (t('goalCompleted') || 'Done') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
|
|
3719
|
+
{ className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}` },
|
|
3720
|
+
isCompleted ? (t('goalCompleted') || 'Done') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Goal')
|
|
3585
3721
|
),
|
|
3586
3722
|
React.createElement('span', { className: 'dsh-goal-title', title: state.title }, state.title),
|
|
3587
3723
|
React.createElement('span', { className: 'dsh-goal-time' }, `⏱ ${liveElapsedStr}`),
|
|
3724
|
+
isPlanMode && !isCompleted ? React.createElement(
|
|
3725
|
+
'button',
|
|
3726
|
+
{
|
|
3727
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
3728
|
+
style: { padding: '2px 6px', height: 22, fontSize: 11 },
|
|
3729
|
+
title: t('approveAndExecute') || 'Approve & Execute',
|
|
3730
|
+
onClick: () => handleAction('approve_plan'),
|
|
3731
|
+
},
|
|
3732
|
+
'✓ ' + (t('approveAndExecute') || 'Approve')
|
|
3733
|
+
) : null,
|
|
3588
3734
|
!isCompleted ? React.createElement(
|
|
3589
3735
|
'button',
|
|
3590
3736
|
{
|
|
@@ -3644,11 +3790,11 @@ window.__ModuleLoader__.load({
|
|
|
3644
3790
|
|
|
3645
3791
|
{
|
|
3646
3792
|
|
|
3647
|
-
className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
|
|
3793
|
+
className: `dsh-goal-badge ${isCompleted ? 'dsh-goal-badge-ok' : isPlanMode ? 'dsh-goal-badge-plan' : isPaused ? 'dsh-goal-badge-warn' : ''}`,
|
|
3648
3794
|
|
|
3649
3795
|
},
|
|
3650
3796
|
|
|
3651
|
-
isCompleted ? (t('goalCompleted') || 'Goal completed') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
|
|
3797
|
+
isCompleted ? (t('goalCompleted') || 'Goal completed') : isPlanMode ? (t('planMode') || 'Plan Mode') : isPaused ? (t('paused') || 'Paused') : (t('goalLabel') || 'Current goal'),
|
|
3652
3798
|
|
|
3653
3799
|
),
|
|
3654
3800
|
|
|
@@ -3672,6 +3818,18 @@ window.__ModuleLoader__.load({
|
|
|
3672
3818
|
|
|
3673
3819
|
),
|
|
3674
3820
|
|
|
3821
|
+
isPlanMode && !isCompleted
|
|
3822
|
+
? React.createElement(
|
|
3823
|
+
'button',
|
|
3824
|
+
{
|
|
3825
|
+
className: 'dsh-goal-btn dsh-goal-btn-approve',
|
|
3826
|
+
style: { fontSize: 12, padding: '2px 8px' },
|
|
3827
|
+
title: t('approveAndExecute') || 'Approve & Execute',
|
|
3828
|
+
onClick: () => handleAction('approve_plan'),
|
|
3829
|
+
},
|
|
3830
|
+
'✓ ' + (t('approveAndExecute') || 'Approve & Execute')
|
|
3831
|
+
)
|
|
3832
|
+
: null,
|
|
3675
3833
|
!isCompleted
|
|
3676
3834
|
|
|
3677
3835
|
? React.createElement(
|
|
@@ -4983,6 +5141,15 @@ window.__ModuleLoader__.load({
|
|
|
4983
5141
|
toolFailHint: 'Auto-pause goal if N consecutive turns hit tool execution errors (0 to disable)',
|
|
4984
5142
|
|
|
4985
5143
|
notifLabel: 'Native browser desktop notifications on completion',
|
|
5144
|
+
planMode: 'Plan Mode',
|
|
5145
|
+
actMode: 'Act Mode',
|
|
5146
|
+
approveAndExecute: 'Approve & Execute',
|
|
5147
|
+
switchToPlan: 'Switch to Plan Mode',
|
|
5148
|
+
planningPhase: 'Planning Phase (Read-Only)',
|
|
5149
|
+
planPhaseNotice: 'Agent is formulating the plan. File modifications are guarded until operator approval.',
|
|
5150
|
+
launchModeLabel: 'Launch Mode:',
|
|
5151
|
+
planModeDesc: 'Plan first, review milestones, then execute',
|
|
5152
|
+
actModeDesc: 'Direct execution right away',
|
|
4986
5153
|
|
|
4987
5154
|
},
|
|
4988
5155
|
|
|
@@ -5122,6 +5289,15 @@ window.__ModuleLoader__.load({
|
|
|
5122
5289
|
toolFailHint: '连续 N 轮遭遇工具调用错误时自动暂停(0 表示禁用)',
|
|
5123
5290
|
|
|
5124
5291
|
notifLabel: '目标完成时发送浏览器桌面原生通知',
|
|
5292
|
+
planMode: '规划模式',
|
|
5293
|
+
actMode: '执行模式',
|
|
5294
|
+
approveAndExecute: '批准并执行',
|
|
5295
|
+
switchToPlan: '切换到规划模式',
|
|
5296
|
+
planningPhase: '规划阶段(只读)',
|
|
5297
|
+
planPhaseNotice: '智能体正在制定方案与里程碑。在操作者批准前,禁止修改文件。',
|
|
5298
|
+
launchModeLabel: '启动模式:',
|
|
5299
|
+
planModeDesc: '先制定里程碑方案,人工确认后再执行',
|
|
5300
|
+
actModeDesc: '立即开始编码与任务执行',
|
|
5125
5301
|
|
|
5126
5302
|
},
|
|
5127
5303
|
|
package/lib/engine-sessions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GoalState } from './goal-engine-constants.js';
|
|
1
|
+
import { GoalMode, GoalState } from './goal-engine-constants.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Prune inactive sessions when exceeding maxSessions threshold
|
|
@@ -81,3 +81,80 @@ export function extendGoalBudget(goal, addTokens = 50000, defaultBudget = 0) {
|
|
|
81
81
|
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
82
82
|
return newBudget;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Set mode for a goal
|
|
87
|
+
*/
|
|
88
|
+
export function setGoalMode(goal, mode, recordActivity) {
|
|
89
|
+
if (mode !== GoalMode.PLAN && mode !== GoalMode.ACT) {
|
|
90
|
+
throw new Error(`Invalid mode: ${mode}. Must be one of: plan, act`);
|
|
91
|
+
}
|
|
92
|
+
if (!goal) {
|
|
93
|
+
throw new Error('No active goal found');
|
|
94
|
+
}
|
|
95
|
+
const previousMode = goal.mode || GoalMode.ACT;
|
|
96
|
+
goal.mode = mode;
|
|
97
|
+
if (mode === GoalMode.ACT && goal.state === GoalState.PLANNING) {
|
|
98
|
+
goal.state = GoalState.RUNNING;
|
|
99
|
+
} else if (mode === GoalMode.PLAN && goal.state === GoalState.RUNNING) {
|
|
100
|
+
goal.state = GoalState.PLANNING;
|
|
101
|
+
}
|
|
102
|
+
const message = mode === GoalMode.ACT
|
|
103
|
+
? 'Plan approved by operator, switched to Act Mode'
|
|
104
|
+
: 'Switched to Plan Mode (read-only planning phase)';
|
|
105
|
+
goal.logs.push({ timestamp: Date.now(), type: 'info', message });
|
|
106
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
107
|
+
if (typeof recordActivity === 'function') {
|
|
108
|
+
recordActivity(goal, { type: 'control', tool: 'setMode', status: 'info', details: `${previousMode} -> ${mode}` });
|
|
109
|
+
}
|
|
110
|
+
return goal;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pause active goal
|
|
115
|
+
*/
|
|
116
|
+
export function pauseGoal(goal, reason, recordActivity) {
|
|
117
|
+
if (!goal || goal.state !== GoalState.RUNNING) return false;
|
|
118
|
+
goal.state = GoalState.PAUSED;
|
|
119
|
+
goal.pausedAt = Date.now();
|
|
120
|
+
if (typeof recordActivity === 'function') {
|
|
121
|
+
recordActivity(goal, { type: 'control', tool: 'pause', status: 'warning', details: reason });
|
|
122
|
+
}
|
|
123
|
+
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Paused: ${reason}` });
|
|
124
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resume paused goal
|
|
130
|
+
*/
|
|
131
|
+
export function resumeGoal(goal, recordActivity) {
|
|
132
|
+
if (!goal || goal.state !== GoalState.PAUSED) return false;
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
if (goal.pausedAt) {
|
|
135
|
+
goal.totalPausedDurationMs += now - goal.pausedAt;
|
|
136
|
+
goal.pausedAt = null;
|
|
137
|
+
}
|
|
138
|
+
goal.state = GoalState.RUNNING;
|
|
139
|
+
if (typeof recordActivity === 'function') {
|
|
140
|
+
recordActivity(goal, { type: 'control', tool: 'resume', status: 'info', details: 'Resumed by user' });
|
|
141
|
+
}
|
|
142
|
+
goal.logs.push({ timestamp: now, type: 'info', message: 'Goal resumed' });
|
|
143
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Cancel active goal
|
|
149
|
+
*/
|
|
150
|
+
export function cancelGoal(goal, reason, recordActivity) {
|
|
151
|
+
if (!goal) return false;
|
|
152
|
+
goal.state = GoalState.CANCELLED;
|
|
153
|
+
goal.completedAt = Date.now();
|
|
154
|
+
if (typeof recordActivity === 'function') {
|
|
155
|
+
recordActivity(goal, { type: 'control', tool: 'cancel', status: 'warning', details: reason });
|
|
156
|
+
}
|
|
157
|
+
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Cancelled: ${reason}` });
|
|
158
|
+
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
@@ -4,6 +4,11 @@ import path from "node:path";
|
|
|
4
4
|
* Enums and helpers for DSH Goal Engine
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export const GoalMode = {
|
|
8
|
+
PLAN: 'plan',
|
|
9
|
+
ACT: 'act',
|
|
10
|
+
};
|
|
11
|
+
|
|
7
12
|
export const GoalState = {
|
|
8
13
|
IDLE: 'IDLE',
|
|
9
14
|
PLANNING: 'PLANNING',
|
|
@@ -144,6 +149,7 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
144
149
|
return {
|
|
145
150
|
sessionId: sid,
|
|
146
151
|
hasActiveGoal: false,
|
|
152
|
+
mode: GoalMode.ACT,
|
|
147
153
|
state: GoalState.IDLE,
|
|
148
154
|
title: '',
|
|
149
155
|
startedAt: null,
|
|
@@ -199,6 +205,7 @@ export function buildSnapshot(goal, sid, engine) {
|
|
|
199
205
|
sessionId: sid,
|
|
200
206
|
hasActiveGoal: true,
|
|
201
207
|
id: goal.id,
|
|
208
|
+
mode: goal.mode || (goal.state === GoalState.PLANNING ? GoalMode.PLAN : GoalMode.ACT),
|
|
202
209
|
state: goal.state,
|
|
203
210
|
title: goal.title,
|
|
204
211
|
description: goal.description,
|
package/lib/goal-engine.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
|
|
1
|
+
import { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, calculateElapsedSeconds, calculateRemainingSeconds, buildSnapshot, getSafeCwd } from './goal-engine-constants.js';
|
|
2
2
|
import { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact } from './engine-reports.js';
|
|
3
3
|
import { EngineStore } from './engine-store.js';
|
|
4
4
|
import { buildStatePromptInjection } from './engine-prompt.js';
|
|
@@ -8,9 +8,9 @@ import { createGoalBranch, sanitizeBranchSlug, getCurrentGitBranch, mergeGoalBra
|
|
|
8
8
|
import { parseIssueRef, parseIssueChecklist, syncMilestoneWithIssue } from './engine-issue-sync.js';
|
|
9
9
|
import { generateRetrospectiveData } from './engine-retrospective.js';
|
|
10
10
|
import { generatePreplanDraft } from './engine-templates.js';
|
|
11
|
-
import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget } from './engine-sessions.js';
|
|
11
|
+
import { pruneInactiveSessions, addNudgeToGoal, consumeGoalNudge, extendGoalBudget, setGoalMode, pauseGoal, resumeGoal, cancelGoal } from './engine-sessions.js';
|
|
12
12
|
|
|
13
|
-
export { GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
|
|
13
|
+
export { GoalMode, GoalState, MilestoneStatus, formatElapsed, formatETA, detectLanguage, sessionIdOf, matchMilestone, parseIssueChecklist };
|
|
14
14
|
export { getGitCurrentCommit, exportReportMarkdown, exportReportGitHubPR, createMilestoneCheckpoint, rollbackToCheckpoint, saveGoalArtifact };
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -193,6 +193,9 @@ export class GoalEngine {
|
|
|
193
193
|
? options.gitStartCommit
|
|
194
194
|
: getGitCurrentCommit(options.cwd);
|
|
195
195
|
|
|
196
|
+
const initialMode = options.mode === GoalMode.PLAN ? GoalMode.PLAN : GoalMode.ACT;
|
|
197
|
+
const initialState = initialMode === GoalMode.PLAN ? GoalState.PLANNING : GoalState.RUNNING;
|
|
198
|
+
|
|
196
199
|
const goal = {
|
|
197
200
|
id: `goal-${now}-${Math.random().toString(36).substring(2, 7)}`,
|
|
198
201
|
sessionId: sid,
|
|
@@ -200,7 +203,8 @@ export class GoalEngine {
|
|
|
200
203
|
description: options.description?.trim() || '',
|
|
201
204
|
lang: options.lang || detectLanguage(cleanTitle),
|
|
202
205
|
cwd: options.cwd ? getSafeCwd(options.cwd) : null,
|
|
203
|
-
|
|
206
|
+
mode: initialMode,
|
|
207
|
+
state: initialState,
|
|
204
208
|
startedAt: now,
|
|
205
209
|
pausedAt: null,
|
|
206
210
|
totalPausedDurationMs: 0,
|
|
@@ -265,13 +269,7 @@ export class GoalEngine {
|
|
|
265
269
|
pause(reason = 'User requested pause', sessionId = 'default') {
|
|
266
270
|
const sid = sessionId || 'default';
|
|
267
271
|
const goal = this.goals.get(sid);
|
|
268
|
-
if (!goal
|
|
269
|
-
|
|
270
|
-
goal.state = GoalState.PAUSED;
|
|
271
|
-
goal.pausedAt = Date.now();
|
|
272
|
-
recordActivity(goal, { type: 'control', tool: 'pause', status: 'warning', details: reason });
|
|
273
|
-
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Paused: ${reason}` });
|
|
274
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
272
|
+
if (!pauseGoal(goal, reason, recordActivity)) return this.getSnapshot(sid);
|
|
275
273
|
this.emit(sid, true);
|
|
276
274
|
return this.getSnapshot(sid);
|
|
277
275
|
}
|
|
@@ -279,18 +277,8 @@ export class GoalEngine {
|
|
|
279
277
|
resume(sessionId = 'default') {
|
|
280
278
|
const sid = sessionId || 'default';
|
|
281
279
|
const goal = this.goals.get(sid);
|
|
282
|
-
if (!goal
|
|
283
|
-
|
|
284
|
-
const now = Date.now();
|
|
285
|
-
if (goal.pausedAt) {
|
|
286
|
-
goal.totalPausedDurationMs += now - goal.pausedAt;
|
|
287
|
-
goal.pausedAt = null;
|
|
288
|
-
}
|
|
289
|
-
goal.state = GoalState.RUNNING;
|
|
280
|
+
if (!resumeGoal(goal, recordActivity)) return this.getSnapshot(sid);
|
|
290
281
|
this.stallCounters.set(sid, 0);
|
|
291
|
-
recordActivity(goal, { type: 'control', tool: 'resume', status: 'info', details: 'Resumed by user' });
|
|
292
|
-
goal.logs.push({ timestamp: now, type: 'info', message: 'Goal resumed' });
|
|
293
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
294
282
|
this.emit(sid, true);
|
|
295
283
|
return this.getSnapshot(sid);
|
|
296
284
|
}
|
|
@@ -298,13 +286,7 @@ export class GoalEngine {
|
|
|
298
286
|
cancel(reason = 'Cancelled by user', sessionId = 'default') {
|
|
299
287
|
const sid = sessionId || 'default';
|
|
300
288
|
const goal = this.goals.get(sid);
|
|
301
|
-
if (!goal) return null;
|
|
302
|
-
|
|
303
|
-
goal.state = GoalState.CANCELLED;
|
|
304
|
-
goal.completedAt = Date.now();
|
|
305
|
-
recordActivity(goal, { type: 'control', tool: 'cancel', status: 'warning', details: reason });
|
|
306
|
-
goal.logs.push({ timestamp: Date.now(), type: 'warning', message: `Cancelled: ${reason}` });
|
|
307
|
-
if (goal.logs.length > 100) goal.logs = goal.logs.slice(-100);
|
|
289
|
+
if (!cancelGoal(goal, reason, recordActivity)) return null;
|
|
308
290
|
this.emit(sid, true);
|
|
309
291
|
return this.getSnapshot(sid);
|
|
310
292
|
}
|
|
@@ -580,4 +562,26 @@ export class GoalEngine {
|
|
|
580
562
|
const goal = this.goals.get(sid);
|
|
581
563
|
if (goal) recordActivity(goal, { type: 'tool', tool, status, details });
|
|
582
564
|
}
|
|
565
|
+
|
|
566
|
+
setMode(mode, sessionId = 'default') {
|
|
567
|
+
const sid = sessionId || 'default';
|
|
568
|
+
const goal = this.goals.get(sid);
|
|
569
|
+
if (!goal) throw new Error(`No active goal for session "${sid}"`);
|
|
570
|
+
setGoalMode(goal, mode, recordActivity);
|
|
571
|
+
this.emit(sid, true);
|
|
572
|
+
return this.getSnapshot(sid);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
approvePlan(sessionId = 'default') {
|
|
576
|
+
return this.setMode(GoalMode.ACT, sessionId);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
getActiveGoal(sessionId = 'default') {
|
|
580
|
+
return this.goals.get(sessionId || 'default') || null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
isPlanMode(sessionId = 'default') {
|
|
584
|
+
const goal = this.goals.get(sessionId || 'default');
|
|
585
|
+
return Boolean(goal && (goal.mode === GoalMode.PLAN || goal.state === GoalState.PLANNING));
|
|
586
|
+
}
|
|
583
587
|
}
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { GitSnapshotManager } from './git-snapshot-manager.js';
|
|
|
19
19
|
import { MilestoneManager } from './milestone-manager.js';
|
|
20
20
|
import { registerRoutes } from './routes.js';
|
|
21
21
|
import { registerTools } from './tools.js';
|
|
22
|
+
import { registerPlanModeGuard, createPlanModeGuard, isPermittedInPlanMode } from './tool-guard.js';
|
|
22
23
|
import { saveGoalReportFile } from './engine-reports.js';
|
|
23
24
|
import { registerPluginUpdater } from './updater.js';
|
|
24
25
|
import { GoalEngine, GoalState } from './goal-engine.js';
|
|
@@ -168,6 +169,7 @@ export function apply(ctx, config = {}) {
|
|
|
168
169
|
enableMilestoneDependencies: currentSettings.enableMilestoneDependencies,
|
|
169
170
|
logger,
|
|
170
171
|
});
|
|
172
|
+
registerPlanModeGuard(ctx, { engine: fallbackEngine, logger });
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
// 1. One-click auto-updater registration (Issue #99)
|
|
@@ -414,3 +416,5 @@ export function apply(ctx, config = {}) {
|
|
|
414
416
|
engine: fallbackEngine,
|
|
415
417
|
};
|
|
416
418
|
}
|
|
419
|
+
|
|
420
|
+
export { registerPlanModeGuard, createPlanModeGuard, isPermittedInPlanMode };
|
package/lib/routes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { MilestoneStatus, detectLanguage, sessionIdOf, isPathInsideOrEqual, getSafeCwd } from './goal-engine-constants.js';
|
|
3
|
+
import { MilestoneStatus, GoalMode, detectLanguage, sessionIdOf, isPathInsideOrEqual, getSafeCwd } from './goal-engine-constants.js';
|
|
4
4
|
import { isValidGitRef } from './engine-git-branch.js';
|
|
5
5
|
import { saveGoalArtifact, rollbackToCheckpoint } from './engine-reports.js';
|
|
6
6
|
import { getTemplatesCatalogue, instantiateTemplate } from './engine-templates.js';
|
|
@@ -44,76 +44,164 @@ export function isTrustedHost(val) {
|
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return
|
|
47
|
+
/**
|
|
48
|
+
* Safely extract DSH connection service from Cordis Context if available (#172).
|
|
49
|
+
* @param {any} ctx
|
|
50
|
+
* @returns {any}
|
|
51
|
+
*/
|
|
52
|
+
export function getConnection(ctx) {
|
|
53
|
+
if (!ctx) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const fromCtx = ctx?.reflect?.get
|
|
56
|
+
? (ctx.reflect.get('connection') || ctx?.connection)
|
|
57
|
+
: (ctx?.get ? (ctx.get('connection') || ctx?.connection) : Reflect.get(ctx, 'connection'));
|
|
58
|
+
if (fromCtx) return fromCtx;
|
|
59
|
+
if (ctx?.root) {
|
|
60
|
+
return getConnection(ctx.root);
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
59
65
|
}
|
|
66
|
+
}
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
function resolveAuthOptions(optionsOrToken, maybeCtx) {
|
|
69
|
+
let authToken = process.env.DSH_GOAL_AUTH_TOKEN;
|
|
70
|
+
let ctx = null;
|
|
71
|
+
let connection = null;
|
|
72
|
+
|
|
73
|
+
if (typeof optionsOrToken === 'string') {
|
|
74
|
+
authToken = optionsOrToken;
|
|
75
|
+
ctx = maybeCtx;
|
|
76
|
+
} else if (optionsOrToken && typeof optionsOrToken === 'object') {
|
|
77
|
+
if (optionsOrToken.authToken !== undefined) authToken = optionsOrToken.authToken;
|
|
78
|
+
if (optionsOrToken.connection !== undefined) connection = optionsOrToken.connection;
|
|
79
|
+
if (optionsOrToken.ctx !== undefined) {
|
|
80
|
+
ctx = optionsOrToken.ctx;
|
|
81
|
+
} else if (optionsOrToken.webServer || optionsOrToken.reflect || optionsOrToken.connectionService) {
|
|
82
|
+
ctx = optionsOrToken;
|
|
83
|
+
}
|
|
65
84
|
}
|
|
85
|
+
if (!connection && ctx) {
|
|
86
|
+
connection = getConnection(ctx);
|
|
87
|
+
}
|
|
88
|
+
return { authToken, ctx, connection };
|
|
89
|
+
}
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Evaluates whether an incoming HTTP request is trusted and authenticated (#172).
|
|
93
|
+
*
|
|
94
|
+
* Requirements:
|
|
95
|
+
* 1. Strict CSRF guard: rejects sec-fetch-site cross-site or same-site with 403.
|
|
96
|
+
* 2. DNS rebinding & WAN protection: validates Host and remoteAddress against isTrustedHost.
|
|
97
|
+
* 3. Origin & Referer headers must match Host and pass isTrustedHost.
|
|
98
|
+
* 4. Explicit auth token: valid X-DSH-Auth-Token or Authorization: Bearer bypasses session check.
|
|
99
|
+
* 5. Loopback callers (127.0.0.1, ::1) are admitted for local CLI, health checks, and monitoring.
|
|
100
|
+
* 6. Remote LAN callers must be authenticated via DSH connection service (connection.requestRejection).
|
|
101
|
+
* If connection service is absent or returns a rejection code, caller is rejected (fail-closed, 401/403).
|
|
102
|
+
*
|
|
103
|
+
* @param {import('node:http').IncomingMessage} req
|
|
104
|
+
* @param {string|object} [optionsOrToken]
|
|
105
|
+
* @param {any} [maybeCtx]
|
|
106
|
+
* @returns {number|undefined} Rejection HTTP status code (401, 403) or undefined if admitted.
|
|
107
|
+
*/
|
|
108
|
+
export function getRequestRejection(req, optionsOrToken = process.env.DSH_GOAL_AUTH_TOKEN, maybeCtx = null) {
|
|
109
|
+
if (!req || !req.headers) return 403;
|
|
110
|
+
|
|
111
|
+
const { authToken, ctx, connection } = resolveAuthOptions(optionsOrToken, maybeCtx);
|
|
112
|
+
|
|
113
|
+
// 1. Cross-site and same-site browser requests are rejected (CSRF guard)
|
|
114
|
+
const site = req.headers['sec-fetch-site'];
|
|
115
|
+
if (site === 'cross-site' || site === 'same-site') return 403;
|
|
116
|
+
|
|
117
|
+
// 2. Host header validation (DNS rebinding guard)
|
|
118
|
+
const host = String(req.headers.host || '').trim().toLowerCase();
|
|
119
|
+
if (host && !isTrustedHost(host)) return 403;
|
|
120
|
+
|
|
121
|
+
// 3. Remote address validation if socket is present
|
|
122
|
+
const rawRemote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
|
|
123
|
+
const cleanRemote = String(rawRemote).trim().toLowerCase().replace(/^::ffff:/, '');
|
|
124
|
+
if (cleanRemote && !isTrustedHost(cleanRemote)) return 403;
|
|
125
|
+
|
|
126
|
+
// 4. Origin header validation if present
|
|
127
|
+
const origin = req.headers.origin;
|
|
128
|
+
if (origin !== undefined) {
|
|
129
|
+
if (origin === 'null' || !origin) return 403;
|
|
130
|
+
try {
|
|
131
|
+
const u = new URL(origin);
|
|
132
|
+
if (!isTrustedHost(u.hostname)) return 403;
|
|
133
|
+
if (host && u.host.toLowerCase() !== host.toLowerCase()) return 403;
|
|
134
|
+
} catch {
|
|
135
|
+
return 403;
|
|
77
136
|
}
|
|
78
137
|
}
|
|
79
138
|
|
|
80
|
-
// 5.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!isTrustedHost(u.hostname)) return
|
|
87
|
-
if (u.host
|
|
88
|
-
}
|
|
89
|
-
|
|
139
|
+
// 5. Referer header validation if present
|
|
140
|
+
const referer = req.headers.referer;
|
|
141
|
+
if (referer !== undefined && referer) {
|
|
142
|
+
try {
|
|
143
|
+
const u = new URL(referer);
|
|
144
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') {
|
|
145
|
+
if (!isTrustedHost(u.hostname)) return 403;
|
|
146
|
+
if (host && u.host.toLowerCase() !== host.toLowerCase()) return 403;
|
|
147
|
+
} else {
|
|
148
|
+
const isLocal = isLoopback(cleanRemote) || (host ? isLoopback(host.split(':')[0]) : false);
|
|
149
|
+
if (!isLocal) return 403;
|
|
90
150
|
}
|
|
151
|
+
} catch {
|
|
152
|
+
return 403;
|
|
91
153
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 6. Explicit auth token check (supports x-dsh-auth-token or Authorization: Bearer <token>)
|
|
157
|
+
if (authToken) {
|
|
158
|
+
const headerVal = req.headers['x-dsh-auth-token'] || req.headers.authorization;
|
|
159
|
+
const cleanHeader = headerVal ? String(headerVal).replace(/^Bearer\s+/i, '').trim() : '';
|
|
160
|
+
if (cleanHeader && cleanHeader === authToken) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (cleanRemote && !isLoopback(cleanRemote)) {
|
|
164
|
+
return 401;
|
|
101
165
|
}
|
|
102
|
-
return false;
|
|
103
166
|
}
|
|
104
167
|
|
|
105
|
-
//
|
|
106
|
-
if (isLoopback(cleanRemote))
|
|
168
|
+
// 7. Verified loopback callers (local CLI, health checks, deploy.sh verify)
|
|
169
|
+
if (isLoopback(cleanRemote)) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
107
172
|
|
|
108
|
-
//
|
|
173
|
+
// Mock test requests without remoteAddress where host targets loopback
|
|
109
174
|
if (!cleanRemote && host) {
|
|
110
|
-
const hostName = host.split(
|
|
175
|
+
const hostName = host.split(':')[0];
|
|
111
176
|
if (isLoopback(hostName)) {
|
|
112
|
-
return
|
|
177
|
+
return undefined;
|
|
113
178
|
}
|
|
114
179
|
}
|
|
115
180
|
|
|
116
|
-
|
|
181
|
+
// 8. Remote / LAN callers must be authenticated via DSH connection service (#172)
|
|
182
|
+
const conn = connection || (ctx && getConnection(ctx)) || req.connectionService;
|
|
183
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
184
|
+
let rejection;
|
|
185
|
+
try {
|
|
186
|
+
rejection = conn.requestRejection(req);
|
|
187
|
+
} catch {
|
|
188
|
+
return 403;
|
|
189
|
+
}
|
|
190
|
+
if (rejection !== undefined) {
|
|
191
|
+
if (typeof rejection === 'number' && Number.isInteger(rejection) && rejection >= 400 && rejection < 600) {
|
|
192
|
+
return rejection;
|
|
193
|
+
}
|
|
194
|
+
return rejection === 401 ? 401 : 403;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 9. Fail-closed: Remote/LAN caller without connection service or valid token is rejected
|
|
200
|
+
return 401;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isTrustedCaller(req, optionsOrToken = process.env.DSH_GOAL_AUTH_TOKEN, maybeCtx = null) {
|
|
204
|
+
return getRequestRejection(req, optionsOrToken, maybeCtx) === undefined;
|
|
117
205
|
}
|
|
118
206
|
|
|
119
207
|
export { isPathInsideOrEqual, getSafeCwd };
|
|
@@ -218,10 +306,11 @@ export function registerRoutes(ctx, {
|
|
|
218
306
|
|
|
219
307
|
// GET /dsh-goal/events: Server-Sent Events realtime snapshot stream
|
|
220
308
|
if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
|
|
221
|
-
|
|
222
|
-
|
|
309
|
+
const rejection = getRequestRejection(req, { ctx });
|
|
310
|
+
if (rejection !== undefined) {
|
|
311
|
+
res.statusCode = rejection;
|
|
223
312
|
if (typeof res.setHeader === 'function') res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
224
|
-
return res.end(JSON.stringify({ error: 'Forbidden: untrusted caller origin' }));
|
|
313
|
+
return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
|
|
225
314
|
}
|
|
226
315
|
const sid = sessionIdOf(req, 'default');
|
|
227
316
|
if (typeof res.writeHead === 'function') {
|
|
@@ -265,9 +354,10 @@ export function registerRoutes(ctx, {
|
|
|
265
354
|
|
|
266
355
|
// GET /dsh-goal/templates
|
|
267
356
|
if (req.method === 'GET' && (pathname === '/dsh-goal/templates' || pathname === '/dsh-goal/templates/')) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
357
|
+
const rejection = getRequestRejection(req, { ctx });
|
|
358
|
+
if (rejection !== undefined) {
|
|
359
|
+
res.statusCode = rejection;
|
|
360
|
+
return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
|
|
271
361
|
}
|
|
272
362
|
res.statusCode = 200;
|
|
273
363
|
return res.end(JSON.stringify({ ok: true, templates: getTemplatesCatalogue() }));
|
|
@@ -275,9 +365,10 @@ export function registerRoutes(ctx, {
|
|
|
275
365
|
|
|
276
366
|
// GET /dsh-goal/state
|
|
277
367
|
if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
368
|
+
const rejection = getRequestRejection(req, { ctx });
|
|
369
|
+
if (rejection !== undefined) {
|
|
370
|
+
res.statusCode = rejection;
|
|
371
|
+
return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
|
|
281
372
|
}
|
|
282
373
|
const sid = sessionIdOf(req, 'default');
|
|
283
374
|
res.statusCode = 200;
|
|
@@ -316,9 +407,10 @@ export function registerRoutes(ctx, {
|
|
|
316
407
|
}
|
|
317
408
|
}
|
|
318
409
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
410
|
+
const rejection = getRequestRejection(req, { ctx });
|
|
411
|
+
if (rejection !== undefined) {
|
|
412
|
+
res.statusCode = rejection;
|
|
413
|
+
return res.end(JSON.stringify({ error: rejection === 401 ? 'Unauthorized: authentication required' : 'Forbidden: untrusted caller origin' }));
|
|
322
414
|
}
|
|
323
415
|
|
|
324
416
|
let body = '';
|
|
@@ -375,24 +467,32 @@ export function registerRoutes(ctx, {
|
|
|
375
467
|
lang: detectedLang,
|
|
376
468
|
cwd: safeStartCwd,
|
|
377
469
|
issueBody: data.issueBody,
|
|
470
|
+
mode: data.mode,
|
|
378
471
|
}, sid);
|
|
379
472
|
} else {
|
|
380
473
|
let parsedMilestones = [];
|
|
381
474
|
if (data.issueBody) {
|
|
382
475
|
parsedMilestones = parseIssueChecklist(data.issueBody);
|
|
383
476
|
}
|
|
477
|
+
const initialMode = data.mode === 'plan' ? 'plan' : 'act';
|
|
384
478
|
result = {
|
|
385
479
|
title: cleanTitle,
|
|
386
480
|
sessionId: sid,
|
|
387
481
|
hasActiveGoal: true,
|
|
388
|
-
|
|
482
|
+
mode: initialMode,
|
|
483
|
+
state: initialMode === 'plan' ? 'PLANNING' : 'RUNNING',
|
|
389
484
|
milestones: parsedMilestones,
|
|
390
485
|
};
|
|
391
486
|
}
|
|
392
487
|
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
488
|
+
const isPlan = result?.mode === 'plan' || (engine && typeof engine.isPlanMode === 'function' && engine.isPlanMode(sid));
|
|
489
|
+
const startPrompt = isPlan
|
|
490
|
+
? (detectedLang === 'zh'
|
|
491
|
+
? `🎯 目标已确立(规划模式/Plan Mode):“${cleanTitle}”。请通过 goal_milestones 制定工作计划并列出检查清单。在操作者批准前,请勿进行写文件等变更操作。`
|
|
492
|
+
: `🎯 Goal established in Plan Mode: "${cleanTitle}". Formulate milestones and checklist via goal_milestones and goal_checklist. Do not modify files until the operator approves the plan.`)
|
|
493
|
+
: (detectedLang === 'zh'
|
|
494
|
+
? `🎯 目标已确立:“${cleanTitle}”。请立即通过 goal_milestones 制定工作计划并开始执行。`
|
|
495
|
+
: `🎯 Goal established: "${cleanTitle}". Formulate milestones via goal_milestones and proceed.`);
|
|
396
496
|
|
|
397
497
|
if (typeof resumeActiveAgent === 'function') {
|
|
398
498
|
resumeActiveAgent(startPrompt, sid);
|
|
@@ -622,6 +722,55 @@ export function registerRoutes(ctx, {
|
|
|
622
722
|
break;
|
|
623
723
|
}
|
|
624
724
|
|
|
725
|
+
case 'set_mode': {
|
|
726
|
+
const targetMode = data.mode;
|
|
727
|
+
if (targetMode !== GoalMode.PLAN && targetMode !== GoalMode.ACT) {
|
|
728
|
+
res.statusCode = 400;
|
|
729
|
+
return res.end(JSON.stringify({ error: `Invalid mode "${targetMode}". Must be "plan" or "act".` }));
|
|
730
|
+
}
|
|
731
|
+
if (engine) {
|
|
732
|
+
const snap = engine.getSnapshot(sid);
|
|
733
|
+
if (!snap || !snap.hasActiveGoal) {
|
|
734
|
+
res.statusCode = 404;
|
|
735
|
+
return res.end(JSON.stringify({ error: 'No active goal found to set mode' }));
|
|
736
|
+
}
|
|
737
|
+
result = engine.setMode(targetMode, sid);
|
|
738
|
+
if (targetMode === GoalMode.ACT) {
|
|
739
|
+
const detectedLang = snap.lang || 'en';
|
|
740
|
+
const prompt = detectedLang === 'zh'
|
|
741
|
+
? '⚡ 执行模式(Act Mode)已激活。请继续推进目标任务。'
|
|
742
|
+
: '⚡ Act Mode activated by operator. Proceed with task execution.';
|
|
743
|
+
if (typeof resumeActiveAgent === 'function') {
|
|
744
|
+
resumeActiveAgent(prompt, sid);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
result = { mode: targetMode, state: targetMode === GoalMode.PLAN ? 'PLANNING' : 'RUNNING' };
|
|
749
|
+
}
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'approve_plan': {
|
|
754
|
+
if (engine) {
|
|
755
|
+
const snap = engine.getSnapshot(sid);
|
|
756
|
+
if (!snap || !snap.hasActiveGoal) {
|
|
757
|
+
res.statusCode = 404;
|
|
758
|
+
return res.end(JSON.stringify({ error: 'No active goal found to approve plan' }));
|
|
759
|
+
}
|
|
760
|
+
result = engine.approvePlan(sid);
|
|
761
|
+
const detectedLang = snap.lang || 'en';
|
|
762
|
+
const prompt = detectedLang === 'zh'
|
|
763
|
+
? '✅ 操作者已批准计划。执行模式(Act Mode)已激活。请立即开始具体代码与任务实现。'
|
|
764
|
+
: '✅ Plan approved by operator. Act Mode activated. Proceed with implementation.';
|
|
765
|
+
if (typeof resumeActiveAgent === 'function') {
|
|
766
|
+
resumeActiveAgent(prompt, sid);
|
|
767
|
+
}
|
|
768
|
+
} else {
|
|
769
|
+
result = { mode: GoalMode.ACT, state: 'RUNNING', approved: true };
|
|
770
|
+
}
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
625
774
|
case 'branch_action': {
|
|
626
775
|
const op = String(data.operation || data.branchOperation || 'merge').toLowerCase();
|
|
627
776
|
const targetBranch = String(data.targetBranch || 'main');
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Mode tool execution guard for @goodandready/dsh-goal.
|
|
3
|
+
* Blocks mutating file operations and commands during the planning phase.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
7
|
+
'view_file',
|
|
8
|
+
'read_file',
|
|
9
|
+
'read_url_content',
|
|
10
|
+
'search_web',
|
|
11
|
+
'search',
|
|
12
|
+
'grep',
|
|
13
|
+
'glob',
|
|
14
|
+
'find',
|
|
15
|
+
'list_dir',
|
|
16
|
+
'dir_list',
|
|
17
|
+
'code_search',
|
|
18
|
+
'read_resource',
|
|
19
|
+
'list_resources',
|
|
20
|
+
'brain_recall',
|
|
21
|
+
'brain_list',
|
|
22
|
+
'brain_status',
|
|
23
|
+
'brain_remember',
|
|
24
|
+
'ask_question',
|
|
25
|
+
'send_message',
|
|
26
|
+
'schedule',
|
|
27
|
+
'manage_task',
|
|
28
|
+
'get_goal',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export const FILE_WRITER_TOOLS = new Set([
|
|
32
|
+
'write_to_file',
|
|
33
|
+
'replace_file_content',
|
|
34
|
+
'edit_file',
|
|
35
|
+
'apply_diff',
|
|
36
|
+
'patch',
|
|
37
|
+
'create_file',
|
|
38
|
+
'delete_file',
|
|
39
|
+
'remove_file',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const SAFE_COMMAND_PATTERN = /^(git\s+(status|diff|log|branch|show)|ls|dir|pwd|cat|head|tail|grep|find|wc|echo|which|where)(\s+|$)/i;
|
|
43
|
+
const MUTATING_COMMAND_PATTERN = /(\b(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|sed\s+-i|git\s+(commit|checkout\s+-b|push|merge|rebase|reset|restore))\b|[>|&;])/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check whether a tool is permitted in Plan Mode.
|
|
47
|
+
* @param {string} toolName
|
|
48
|
+
* @param {object} [args]
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isPermittedInPlanMode(toolName, args = {}) {
|
|
52
|
+
if (!toolName || typeof toolName !== 'string') return false;
|
|
53
|
+
|
|
54
|
+
// Milestone and checklist tools are explicitly permitted in plan mode
|
|
55
|
+
if (toolName === 'goal_milestones' || toolName === 'goal_checklist') {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Explicit file writing tools are strictly blocked
|
|
60
|
+
if (FILE_WRITER_TOOLS.has(toolName)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check command execution
|
|
65
|
+
if (toolName === 'run_command' || toolName === 'execute_command' || toolName === 'bash') {
|
|
66
|
+
const rawCmd = (args.CommandLine || args.command || args.cmd || '').trim();
|
|
67
|
+
if (!rawCmd) return true;
|
|
68
|
+
if (SAFE_COMMAND_PATTERN.test(rawCmd) && !MUTATING_COMMAND_PATTERN.test(rawCmd)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Allow known read-only inspection tools
|
|
75
|
+
if (READ_ONLY_TOOLS.has(toolName)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Default fail-closed for unknown tools in plan mode
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a tool guard function for Cordis ctx.tools.guard.
|
|
85
|
+
* @param {import('./goal-engine.js').GoalEngine} engine
|
|
86
|
+
* @param {object} [logger]
|
|
87
|
+
* @returns {(execution: object) => string | undefined}
|
|
88
|
+
*/
|
|
89
|
+
export function createPlanModeGuard(engine, logger = console) {
|
|
90
|
+
return function planModeGuard(execution) {
|
|
91
|
+
if (!engine || typeof engine.isPlanMode !== 'function') return undefined;
|
|
92
|
+
|
|
93
|
+
const sid = execution?.sessionId || execution?.session?.id || 'default';
|
|
94
|
+
if (!engine.isPlanMode(sid)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const toolName = execution?.name || execution?.tool || '';
|
|
99
|
+
if (isPermittedInPlanMode(toolName, execution?.arguments)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rejection = `[Plan Mode Active] Tool "${toolName}" is blocked in Plan Mode. The goal is in the planning phase and file modifications are restricted until the plan is approved by the operator. Use goal_milestones to define the milestones and ask the operator to approve the plan or switch to Act Mode.`;
|
|
104
|
+
logger?.warn?.(`[dsh-goal] Blocked tool execution in Plan Mode: ${toolName}`);
|
|
105
|
+
return rejection;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Register the plan mode guard with Cordis context.
|
|
111
|
+
* @param {any} ctx
|
|
112
|
+
* @param {object} options
|
|
113
|
+
* @param {import('./goal-engine.js').GoalEngine} options.engine
|
|
114
|
+
* @param {object} [options.logger]
|
|
115
|
+
*/
|
|
116
|
+
export function registerPlanModeGuard(ctx, { engine, logger = console } = {}) {
|
|
117
|
+
if (!ctx || !engine) return;
|
|
118
|
+
|
|
119
|
+
const guard = createPlanModeGuard(engine, logger);
|
|
120
|
+
|
|
121
|
+
// 1. Cordis tools service guard
|
|
122
|
+
ctx.inject(['tools'], (tctx) => {
|
|
123
|
+
if (typeof tctx.tools?.guard === 'function') {
|
|
124
|
+
try {
|
|
125
|
+
const unguard = tctx.tools.guard(guard);
|
|
126
|
+
if (typeof unguard === 'function' && typeof tctx.effect === 'function') {
|
|
127
|
+
tctx.effect(() => () => {
|
|
128
|
+
try { unguard(); } catch (e) { /* ignore */ }
|
|
129
|
+
}, 'dsh-goal: plan mode tool guard');
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger?.warn?.('[dsh-goal] Failed to register tools guard:', err.message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// 2. Pre-execute event hook if present
|
|
138
|
+
if (typeof ctx.on === 'function') {
|
|
139
|
+
ctx.on('tools/pre-execute', (execution, next) => {
|
|
140
|
+
const decision = guard(execution);
|
|
141
|
+
if (decision !== undefined) {
|
|
142
|
+
return { kind: 'deny', reason: decision };
|
|
143
|
+
}
|
|
144
|
+
return typeof next === 'function' ? next() : undefined;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
package/package.json
CHANGED