discoclaw 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.context/README.md +2 -0
- package/.context/automations.md +87 -0
- package/dist/config/safe-key.js +38 -0
- package/dist/config/safe-key.test.js +60 -0
- package/dist/config.js +9 -1
- package/dist/discord/action-dispatcher.js +20 -0
- package/dist/discord/action-flags.js +18 -0
- package/dist/discord/actions-config.js +13 -0
- package/dist/discord/actions-imagegen.js +15 -0
- package/dist/discord/actions-imagegen.test.js +113 -1
- package/dist/discord/actions.js +25 -10
- package/dist/discord/actions.test.js +1 -1
- package/dist/discord/allowlist.js +11 -0
- package/dist/discord/message-coordinator.followup-lifecycle.test.js +1 -0
- package/dist/discord/message-coordinator.js +35 -6
- package/dist/discord/plan-manager.js +60 -0
- package/dist/discord/plan-manager.test.js +87 -1
- package/dist/discord/reaction-handler.js +1 -0
- package/dist/discord/replies.js +5 -0
- package/dist/gemini-model-validation.js +20 -0
- package/dist/runtime/cli-adapter.js +12 -0
- package/dist/runtime/cli-adapter.test.js +2 -0
- package/dist/runtime/codex-app-server.js +6 -0
- package/dist/runtime/gemini-rest.js +22 -0
- package/dist/runtime/gemini-rest.test.js +64 -0
- package/dist/runtime/openai-compat.js +22 -1
- package/dist/runtime/strategies/claude-strategy.js +3 -1
- package/dist/server/routes/is-reserved-object-key.js +17 -0
- package/dist/webhook/server.js +6 -1
- package/dist/webhook/server.test.js +15 -0
- package/package.json +2 -2
- package/templates/instructions/SYSTEM_DEFAULTS.md +11 -0
package/.context/README.md
CHANGED
|
@@ -19,6 +19,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
|
|
|
19
19
|
| **Architecture / system overview** | `architecture.md` |
|
|
20
20
|
| **Tool capabilities / browser automation** | `tools.md` |
|
|
21
21
|
| **Voice system (STT/TTS, audio pipeline, actions)** | `voice.md` |
|
|
22
|
+
| **Automations / cron / scheduled tasks** | `automations.md` |
|
|
22
23
|
| **Forge/plan standing constraints** | `project.md` *(auto-loaded by forge)* |
|
|
23
24
|
| **Plan & Forge commands** | `plan-and-forge.md` *(in docs/, not .context/)* |
|
|
24
25
|
| **Engineering lessons / compound learnings** | `docs/compound-lessons.md` *(single checked-in durable artifact; lives in `docs/`, not `.context/`; human/developer reference, not auto-loaded into agent context)* |
|
|
@@ -41,6 +42,7 @@ Core instructions live in `CLAUDE.md` at the repo root.
|
|
|
41
42
|
- **bot-setup.md** — One-time bot creation and invite guide
|
|
42
43
|
- **tools.md** — Available tools: browser automation (agent-browser), escalation ladder, CDP connect, security guardrails
|
|
43
44
|
- **voice.md** — Voice subsystem: module map, audio data flow, key patterns (barge-in, allowlist gating), wiring sequence, dependencies, config reference
|
|
45
|
+
- **automations.md** — Cron & scheduled tasks: job lifecycle, trigger types, primitives (state, silent, routing, chaining), safety rails, config reference
|
|
44
46
|
- **project.md** — Standing constraints auto-loaded by forge drafter and auditor
|
|
45
47
|
- **docs/plan-and-forge.md** — Canonical reference for `!plan` and `!forge` commands (lives in `docs/`, not `.context/` — human/developer reference, not auto-loaded into agent context)
|
|
46
48
|
- **docs/compound-lessons.md** — Single checked-in durable artifact for distilled engineering lessons from audits, forge runs, and repeated workflow failures
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# automations.md — Cron & Scheduled Tasks
|
|
2
|
+
|
|
3
|
+
Quick-reference for automation behavior. For full primitive docs see
|
|
4
|
+
[docs/cron.md](../docs/cron.md); for worked examples and copy-pasteable recipes
|
|
5
|
+
see [docs/cron-patterns.md](../docs/cron-patterns.md).
|
|
6
|
+
|
|
7
|
+
## System Overview
|
|
8
|
+
|
|
9
|
+
Cron jobs are defined as forum threads in a dedicated Discord forum channel.
|
|
10
|
+
The scheduler registers in-process timers (via `croner`); on each tick the
|
|
11
|
+
executor assembles a prompt, invokes the AI runtime, and posts output to a
|
|
12
|
+
target channel.
|
|
13
|
+
|
|
14
|
+
Source: `src/cron/` — scheduler, executor, parser, forum sync, run stats,
|
|
15
|
+
job lock, chain, tag map.
|
|
16
|
+
|
|
17
|
+
## Job Lifecycle
|
|
18
|
+
|
|
19
|
+
| Thread state | Job state |
|
|
20
|
+
|--------------|-----------|
|
|
21
|
+
| Active | Registered and running |
|
|
22
|
+
| Archived | Paused (unregistered) |
|
|
23
|
+
| Unarchived | Resumed (re-registered) |
|
|
24
|
+
| Deleted | Removed; stats cleaned on next startup |
|
|
25
|
+
|
|
26
|
+
Jobs must be created through the bot (`cronCreate` action), not by manually
|
|
27
|
+
creating forum threads.
|
|
28
|
+
|
|
29
|
+
## Trigger Types
|
|
30
|
+
|
|
31
|
+
- **`schedule`** — standard 5-field cron expression
|
|
32
|
+
- **`webhook`** — external HTTP POST (requires `DISCOCLAW_WEBHOOK_ENABLED=true`)
|
|
33
|
+
- **`manual`** — explicit `cronTrigger` action only
|
|
34
|
+
|
|
35
|
+
## Key Primitives
|
|
36
|
+
|
|
37
|
+
| Primitive | Purpose |
|
|
38
|
+
|-----------|---------|
|
|
39
|
+
| `{{state}}` / `<cron-state>` | Persistent per-job key-value state across runs |
|
|
40
|
+
| `silent` mode | Suppress posting when output is a sentinel (`HEARTBEAT_OK` or `[]`) |
|
|
41
|
+
| `routingMode: "json"` | Multi-channel dispatch via JSON array of `{channel, content}` |
|
|
42
|
+
| `allowedActions` | Restrict which Discord action types a job may emit |
|
|
43
|
+
| `chain` | Fire downstream jobs on success, forwarding state via `__upstream` |
|
|
44
|
+
| `model` | Per-job model tier override (`fast` / `capable` / `deep`) |
|
|
45
|
+
|
|
46
|
+
## Safety Rails
|
|
47
|
+
|
|
48
|
+
- Cron jobs **cannot emit cron actions** — hard-coded, not configurable.
|
|
49
|
+
- Overlap guard: one execution per job at a time; concurrent ticks are skipped.
|
|
50
|
+
- Chain depth limit: **10** (prevents runaway cascades).
|
|
51
|
+
- Cycle detection at write time (BFS reachability check).
|
|
52
|
+
- `allowedActions` narrows only — cannot grant permissions the global config denies.
|
|
53
|
+
|
|
54
|
+
## State Essentials
|
|
55
|
+
|
|
56
|
+
- `<cron-state>` **replaces** the full state object (not a merge).
|
|
57
|
+
- `{{state}}` expands to `{}` on first run or after a reset.
|
|
58
|
+
- Reset state: `cronUpdate` with `state: "{}"`.
|
|
59
|
+
- The executor injects a "Persistent State" section capped at 4 000 chars.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
| Variable | Default | Purpose |
|
|
64
|
+
|----------|---------|---------|
|
|
65
|
+
| `DISCOCLAW_CRON_ENABLED` | `true` | Enable the cron subsystem |
|
|
66
|
+
| `DISCOCLAW_CRON_FORUM` | — | Forum channel ID (auto-created if unset) |
|
|
67
|
+
| `DISCOCLAW_CRON_EXEC_MODEL` | `capable` | Default model tier for execution |
|
|
68
|
+
| `DISCOCLAW_CRON_MODEL` | `fast` | Model tier for definition parsing |
|
|
69
|
+
| `DISCOCLAW_CRON_AUTO_TAG` | `true` | Auto-tag cron forum threads |
|
|
70
|
+
| `DISCOCLAW_CRON_STATS_DIR` | — | Override stats storage directory |
|
|
71
|
+
| `DISCOCLAW_CRON_TAG_MAP` | — | Override tag map file path |
|
|
72
|
+
| `DISCOCLAW_WEBHOOK_ENABLED` | `false` | Enable the webhook server |
|
|
73
|
+
| `DISCOCLAW_WEBHOOK_CONFIG` | — | Path to webhook config JSON |
|
|
74
|
+
|
|
75
|
+
## Common Patterns (cheat sheet)
|
|
76
|
+
|
|
77
|
+
| Pattern | Key technique |
|
|
78
|
+
|---------|---------------|
|
|
79
|
+
| Stateful polling | `{{state}}` cursor + `<cron-state>` update |
|
|
80
|
+
| Silent monitoring | `silent: true` + `HEARTBEAT_OK` sentinel |
|
|
81
|
+
| Multi-channel fan-out | `routingMode: "json"` |
|
|
82
|
+
| Chained pipelines | `chain` field + `__upstream.state` handoff |
|
|
83
|
+
| Accumulation / rollup | State counter resets on cadence boundary |
|
|
84
|
+
| Webhook-triggered | Config file + HMAC-SHA256 verification |
|
|
85
|
+
| Gated actions | `allowedActions` for least-privilege |
|
|
86
|
+
|
|
87
|
+
See [docs/cron-patterns.md](../docs/cron-patterns.md) for full examples of each.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards against prototype-pollution via dynamic property names.
|
|
3
|
+
*
|
|
4
|
+
* Any code path that uses an external string (route param, JSON key, etc.)
|
|
5
|
+
* as a plain-object property name should reject these values first.
|
|
6
|
+
*/
|
|
7
|
+
const PROTO_POLLUTION_KEYS = new Set([
|
|
8
|
+
'__proto__',
|
|
9
|
+
'constructor',
|
|
10
|
+
'prototype',
|
|
11
|
+
]);
|
|
12
|
+
/** Returns `true` when `key` is a prototype-pollution property name. */
|
|
13
|
+
export function isUnsafeKey(key) {
|
|
14
|
+
return PROTO_POLLUTION_KEYS.has(key);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Look up `key` on `obj` only when the key is safe and is an own property.
|
|
18
|
+
* Returns `undefined` for unsafe keys or missing properties — never walks
|
|
19
|
+
* the prototype chain.
|
|
20
|
+
*/
|
|
21
|
+
export function safeGet(obj, key) {
|
|
22
|
+
if (isUnsafeKey(key))
|
|
23
|
+
return undefined;
|
|
24
|
+
return Object.hasOwn(obj, key) ? obj[key] : undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return a shallow copy of `obj` with any prototype-pollution keys removed.
|
|
28
|
+
* Useful for sanitising parsed JSON before it becomes a lookup table.
|
|
29
|
+
*/
|
|
30
|
+
export function stripUnsafeKeys(obj) {
|
|
31
|
+
const clean = Object.create(null);
|
|
32
|
+
for (const key of Object.keys(obj)) {
|
|
33
|
+
if (!isUnsafeKey(key)) {
|
|
34
|
+
clean[key] = obj[key];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return clean;
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isUnsafeKey, safeGet, stripUnsafeKeys } from './safe-key.js';
|
|
3
|
+
describe('isUnsafeKey', () => {
|
|
4
|
+
it.each(['__proto__', 'constructor', 'prototype'])('returns true for %s', (key) => {
|
|
5
|
+
expect(isUnsafeKey(key)).toBe(true);
|
|
6
|
+
});
|
|
7
|
+
it.each(['github', 'alerts', '', 'proto', '__proto', 'CONSTRUCTOR'])('returns false for %s', (key) => {
|
|
8
|
+
expect(isUnsafeKey(key)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
describe('safeGet', () => {
|
|
12
|
+
const obj = { a: 1, b: 2 };
|
|
13
|
+
it('returns the value for a safe, present key', () => {
|
|
14
|
+
expect(safeGet(obj, 'a')).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
it('returns undefined for a safe but missing key', () => {
|
|
17
|
+
expect(safeGet(obj, 'z')).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
it('returns undefined for __proto__ even when set as own property', () => {
|
|
20
|
+
const withProto = Object.create(null);
|
|
21
|
+
withProto['__proto__'] = 42;
|
|
22
|
+
expect(safeGet(withProto, '__proto__')).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('returns undefined for constructor', () => {
|
|
25
|
+
expect(safeGet(obj, 'constructor')).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it('returns undefined for prototype', () => {
|
|
28
|
+
expect(safeGet(obj, 'prototype')).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it('does not walk the prototype chain', () => {
|
|
31
|
+
const parent = { inherited: 99 };
|
|
32
|
+
const child = Object.create(parent);
|
|
33
|
+
expect(safeGet(child, 'inherited')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe('stripUnsafeKeys', () => {
|
|
37
|
+
it('removes __proto__, constructor, and prototype keys', () => {
|
|
38
|
+
const input = {
|
|
39
|
+
github: 'ok',
|
|
40
|
+
__proto__: 'bad',
|
|
41
|
+
constructor: 'bad',
|
|
42
|
+
prototype: 'bad',
|
|
43
|
+
alerts: 'ok',
|
|
44
|
+
};
|
|
45
|
+
// JSON.parse puts __proto__ as own property; replicate with Object.defineProperty
|
|
46
|
+
Object.defineProperty(input, '__proto__', { value: 'bad', enumerable: true, configurable: true });
|
|
47
|
+
const result = stripUnsafeKeys(input);
|
|
48
|
+
expect(Object.keys(result)).toEqual(['github', 'alerts']);
|
|
49
|
+
expect(result['github']).toBe('ok');
|
|
50
|
+
expect(result['alerts']).toBe('ok');
|
|
51
|
+
});
|
|
52
|
+
it('returns a null-prototype object', () => {
|
|
53
|
+
const result = stripUnsafeKeys({ a: 1 });
|
|
54
|
+
expect(Object.getPrototypeOf(result)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
it('handles an empty object', () => {
|
|
57
|
+
const result = stripUnsafeKeys({});
|
|
58
|
+
expect(Object.keys(result)).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
|
|
1
|
+
import { isAllowlisted, parseAllowBotIds, parseAllowChannelIds, parseAllowUserIds } from './discord/allowlist.js';
|
|
2
2
|
import { parseDashboardTrustedHosts } from './dashboard/options.js';
|
|
3
3
|
export const KNOWN_TOOLS = new Set([
|
|
4
4
|
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Pipeline', 'Step',
|
|
@@ -9,6 +9,14 @@ export const DEFAULT_DISCORD_ACTIONS_DEFER_MAX_DEPTH = 4;
|
|
|
9
9
|
export const DEFAULT_DISCORD_ACTIONS_LOOP_MIN_INTERVAL_SECONDS = 60;
|
|
10
10
|
export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_INTERVAL_SECONDS = 86400;
|
|
11
11
|
export const DEFAULT_DISCORD_ACTIONS_LOOP_MAX_CONCURRENT = 5;
|
|
12
|
+
/**
|
|
13
|
+
* Check whether a Discord user is authorized for config-mutating actions.
|
|
14
|
+
* Uses the DISCORD_ALLOW_USER_IDS allowlist as the sole authorization source.
|
|
15
|
+
* Fails closed: returns false when the allowlist is empty.
|
|
16
|
+
*/
|
|
17
|
+
export function isAuthorizedUser(config, userId) {
|
|
18
|
+
return isAllowlisted(config.allowUserIds, userId);
|
|
19
|
+
}
|
|
12
20
|
function parseBoolean(env, name, defaultValue) {
|
|
13
21
|
const raw = env[name];
|
|
14
22
|
if (raw == null || raw.trim() === '')
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { isConfigAuthorized } from './allowlist.js';
|
|
2
|
+
import { CONFIG_UNAUTHORIZED_ERROR } from './replies.js';
|
|
3
|
+
import { CONFIG_MUTATING_ACTION_TYPES } from './actions-config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Fail-fast authorization check for config-mutating actions.
|
|
6
|
+
*
|
|
7
|
+
* Returns an error result when the requester is not authorized;
|
|
8
|
+
* returns null when the action is allowed (either non-config or authorized).
|
|
9
|
+
* Missing requester identity denies by default.
|
|
10
|
+
*
|
|
11
|
+
* The protected action set is defined in actions-config.ts
|
|
12
|
+
* (CONFIG_MUTATING_ACTION_TYPES) so coverage is co-located with action definitions.
|
|
13
|
+
*/
|
|
14
|
+
export function checkConfigAuthorization(actionType, requesterId, allowUserIds) {
|
|
15
|
+
if (!CONFIG_MUTATING_ACTION_TYPES.has(actionType))
|
|
16
|
+
return null;
|
|
17
|
+
if (isConfigAuthorized(allowUserIds, requesterId))
|
|
18
|
+
return null;
|
|
19
|
+
return { ok: false, error: CONFIG_UNAUTHORIZED_ERROR };
|
|
20
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip action categories that require a trusted requester context.
|
|
3
|
+
*
|
|
4
|
+
* Used by cron executor and other automated paths where the requester
|
|
5
|
+
* may not be an allowlisted user. Config is included because config
|
|
6
|
+
* mutations (modelSet, modelReset) must only run for allowlisted users.
|
|
7
|
+
*/
|
|
8
|
+
export function withoutRequesterGatedActionFlags(flags) {
|
|
9
|
+
return {
|
|
10
|
+
...flags,
|
|
11
|
+
channels: false,
|
|
12
|
+
messaging: false,
|
|
13
|
+
guild: false,
|
|
14
|
+
moderation: false,
|
|
15
|
+
polls: false,
|
|
16
|
+
config: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -8,6 +8,19 @@ const CONFIG_TYPE_MAP = {
|
|
|
8
8
|
workspaceWarnings: true,
|
|
9
9
|
};
|
|
10
10
|
export const CONFIG_ACTION_TYPES = new Set(Object.keys(CONFIG_TYPE_MAP));
|
|
11
|
+
/**
|
|
12
|
+
* Config action types that mutate bot state and require requester authorization
|
|
13
|
+
* against DISCORD_ALLOW_USER_IDS. Read-only queries (modelShow, workspaceWarnings)
|
|
14
|
+
* are intentionally excluded.
|
|
15
|
+
*
|
|
16
|
+
* All role-scoped mutations — chat, voice, imagegen, fast, cron, forge-drafter,
|
|
17
|
+
* forge-auditor, plan-run, summary, cron-exec — flow through modelSet/modelReset,
|
|
18
|
+
* so this set covers every config-mutation path.
|
|
19
|
+
*/
|
|
20
|
+
export const CONFIG_MUTATING_ACTION_TYPES = new Set([
|
|
21
|
+
'modelSet',
|
|
22
|
+
'modelReset',
|
|
23
|
+
]);
|
|
11
24
|
// ---------------------------------------------------------------------------
|
|
12
25
|
// Role → field mapping
|
|
13
26
|
// ---------------------------------------------------------------------------
|
|
@@ -2,6 +2,7 @@ import { AttachmentBuilder } from 'discord.js';
|
|
|
2
2
|
import { resolveChannel, findChannelRaw, describeChannelType } from './action-utils.js';
|
|
3
3
|
import { NO_MENTIONS } from './allowed-mentions.js';
|
|
4
4
|
import { downloadMessageImages, downloadImageUrl } from './image-download.js';
|
|
5
|
+
import { validateGeminiModelId } from '../gemini-model-validation.js';
|
|
5
6
|
const IMAGEGEN_TYPE_MAP = {
|
|
6
7
|
generateImage: true,
|
|
7
8
|
};
|
|
@@ -16,6 +17,8 @@ const GPT_IMAGE_VALID_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', 'a
|
|
|
16
17
|
const GEMINI_VALID_SIZES = new Set(['1:1', '3:4', '4:3', '9:16', '16:9']);
|
|
17
18
|
const VALID_QUALITY = new Set(['standard', 'hd']);
|
|
18
19
|
const DISCORD_MAX_CONTENT = 2000;
|
|
20
|
+
// Re-export so existing test imports continue to work.
|
|
21
|
+
export { validateGeminiModelId } from '../gemini-model-validation.js';
|
|
19
22
|
// Progress UX
|
|
20
23
|
export const TYPING_INTERVAL_MS = 8_000;
|
|
21
24
|
export const DOT_CYCLE_INTERVAL_MS = 3_000;
|
|
@@ -95,6 +98,9 @@ async function callOpenAI(prompt, model, size, quality, apiKey, baseUrl, signal)
|
|
|
95
98
|
return { ok: true, b64: imageItem.b64_json };
|
|
96
99
|
}
|
|
97
100
|
async function callGemini(prompt, model, size, geminiApiKey, signal) {
|
|
101
|
+
const v = validateGeminiModelId(model);
|
|
102
|
+
if (!v.ok)
|
|
103
|
+
return { ok: false, error: v.error };
|
|
98
104
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict`;
|
|
99
105
|
const body = {
|
|
100
106
|
instances: [{ prompt }],
|
|
@@ -144,6 +150,9 @@ async function callGemini(prompt, model, size, geminiApiKey, signal) {
|
|
|
144
150
|
return { ok: true, b64 };
|
|
145
151
|
}
|
|
146
152
|
async function callGeminiNative(prompt, model, geminiApiKey, sourceImage, signal) {
|
|
153
|
+
const v = validateGeminiModelId(model);
|
|
154
|
+
if (!v.ok)
|
|
155
|
+
return { ok: false, error: v.error };
|
|
147
156
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
|
|
148
157
|
const parts = [];
|
|
149
158
|
if (sourceImage) {
|
|
@@ -251,6 +260,12 @@ export async function executeImagegenAction(action, ctx, imagegenCtx) {
|
|
|
251
260
|
const provider = resolveProvider(model, action.provider);
|
|
252
261
|
const defaultSize = provider === 'gemini' ? DEFAULT_SIZE_GEMINI : DEFAULT_SIZE_OPENAI;
|
|
253
262
|
const size = action.size ?? defaultSize;
|
|
263
|
+
// Validate Gemini model ID before it can reach any URL interpolation
|
|
264
|
+
if (provider === 'gemini') {
|
|
265
|
+
const mv = validateGeminiModelId(model);
|
|
266
|
+
if (!mv.ok)
|
|
267
|
+
return { ok: false, error: mv.error };
|
|
268
|
+
}
|
|
254
269
|
// Per-provider size validation
|
|
255
270
|
if (provider === 'gemini') {
|
|
256
271
|
if (!model.startsWith('gemini-') && !GEMINI_VALID_SIZES.has(size)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { ChannelType } from 'discord.js';
|
|
3
|
-
import { executeImagegenAction, IMAGEGEN_ACTION_TYPES, imagegenActionsPromptSection, resolveDefaultModel, resolveProvider, TYPING_INTERVAL_MS, DOT_CYCLE_INTERVAL_MS, REQUEST_TIMEOUT_MS, } from './actions-imagegen.js';
|
|
3
|
+
import { executeImagegenAction, IMAGEGEN_ACTION_TYPES, imagegenActionsPromptSection, resolveDefaultModel, resolveProvider, validateGeminiModelId, TYPING_INTERVAL_MS, DOT_CYCLE_INTERVAL_MS, REQUEST_TIMEOUT_MS, } from './actions-imagegen.js';
|
|
4
4
|
import { buildTieredDiscordActionsPromptSection, parseDiscordActions } from './actions.js';
|
|
5
5
|
import { buildUnavailableActionTypesNotice } from './output-common.js';
|
|
6
6
|
vi.mock('./image-download.js', async (importOriginal) => {
|
|
@@ -769,6 +769,118 @@ describe('generateImage — Gemini Native', () => {
|
|
|
769
769
|
expect(fetch).not.toHaveBeenCalled();
|
|
770
770
|
});
|
|
771
771
|
});
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// Gemini model ID validation
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
describe('validateGeminiModelId', () => {
|
|
776
|
+
it.each([
|
|
777
|
+
'imagen-4.0-generate-001',
|
|
778
|
+
'imagen-4.0-fast-generate-001',
|
|
779
|
+
'imagen-4.0-ultra-generate-001',
|
|
780
|
+
'gemini-3.1-flash-image-preview',
|
|
781
|
+
'gemini-3-pro-image-preview',
|
|
782
|
+
'dall-e-3',
|
|
783
|
+
'gpt-image-1',
|
|
784
|
+
'my_custom_model.v2',
|
|
785
|
+
])('accepts valid model ID: %s', (model) => {
|
|
786
|
+
expect(validateGeminiModelId(model)).toEqual({ ok: true });
|
|
787
|
+
});
|
|
788
|
+
it.each([
|
|
789
|
+
['../other-model', 'path traversal with ../'],
|
|
790
|
+
['model/../secret', 'embedded traversal'],
|
|
791
|
+
['model/subpath', 'slash in model'],
|
|
792
|
+
['model:extra', 'colon in model'],
|
|
793
|
+
['model%2F..', 'percent-encoded slash'],
|
|
794
|
+
['', 'empty string'],
|
|
795
|
+
[' model', 'leading space'],
|
|
796
|
+
['model name', 'space in model'],
|
|
797
|
+
['model\ttab', 'tab in model'],
|
|
798
|
+
['model\nline', 'newline in model'],
|
|
799
|
+
['.hidden', 'leading dot'],
|
|
800
|
+
['-start', 'leading hyphen'],
|
|
801
|
+
['_start', 'leading underscore'],
|
|
802
|
+
['a'.repeat(129), 'exceeds max length'],
|
|
803
|
+
])('rejects invalid model ID: %s (%s)', (model) => {
|
|
804
|
+
const result = validateGeminiModelId(model);
|
|
805
|
+
expect(result.ok).toBe(false);
|
|
806
|
+
if (!result.ok)
|
|
807
|
+
expect(result.error).toContain('invalid Gemini model identifier');
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
describe('generateImage — Gemini model validation', () => {
|
|
811
|
+
beforeEach(() => {
|
|
812
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiSuccessResponse()));
|
|
813
|
+
});
|
|
814
|
+
afterEach(() => {
|
|
815
|
+
vi.unstubAllGlobals();
|
|
816
|
+
});
|
|
817
|
+
it('rejects traversal payload before any network call (:predict branch)', async () => {
|
|
818
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
819
|
+
const ctx = makeCtx([ch]);
|
|
820
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: '../evil-model', provider: 'gemini', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
821
|
+
expect(result.ok).toBe(false);
|
|
822
|
+
if (!result.ok)
|
|
823
|
+
expect(result.error).toContain('invalid Gemini model identifier');
|
|
824
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
825
|
+
});
|
|
826
|
+
it('rejects traversal payload before any network call (:generateContent branch)', async () => {
|
|
827
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
|
|
828
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
829
|
+
const ctx = makeCtx([ch]);
|
|
830
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'gemini-../../evil', provider: 'gemini' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
831
|
+
expect(result.ok).toBe(false);
|
|
832
|
+
if (!result.ok)
|
|
833
|
+
expect(result.error).toContain('invalid Gemini model identifier');
|
|
834
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
835
|
+
});
|
|
836
|
+
it.each([
|
|
837
|
+
'model/with/slashes',
|
|
838
|
+
'model:with:colons',
|
|
839
|
+
'model%2Fencoded',
|
|
840
|
+
'../traversal',
|
|
841
|
+
'model\nnewline',
|
|
842
|
+
])('rejects reserved/special character model "%s" without network call', async (badModel) => {
|
|
843
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
844
|
+
const ctx = makeCtx([ch]);
|
|
845
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: badModel, provider: 'gemini', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
846
|
+
expect(result.ok).toBe(false);
|
|
847
|
+
if (!result.ok)
|
|
848
|
+
expect(result.error).toContain('invalid Gemini model identifier');
|
|
849
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
850
|
+
});
|
|
851
|
+
it('accepts valid imagen model and reaches network layer', async () => {
|
|
852
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
853
|
+
const ctx = makeCtx([ch]);
|
|
854
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'imagen-4.0-generate-001', size: '1:1' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
855
|
+
expect(result.ok).toBe(true);
|
|
856
|
+
expect(fetch).toHaveBeenCalled();
|
|
857
|
+
});
|
|
858
|
+
it('accepts valid gemini native model and reaches network layer', async () => {
|
|
859
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
|
|
860
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
861
|
+
const ctx = makeCtx([ch]);
|
|
862
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'gemini-3.1-flash-image-preview' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
863
|
+
expect(result.ok).toBe(true);
|
|
864
|
+
expect(fetch).toHaveBeenCalled();
|
|
865
|
+
});
|
|
866
|
+
it('does not validate model for OpenAI provider (no Gemini URL involved)', async () => {
|
|
867
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSuccessResponse()));
|
|
868
|
+
const ch = makeMockChannel({ name: 'art' });
|
|
869
|
+
const ctx = makeCtx([ch]);
|
|
870
|
+
// A model with slashes would be invalid for Gemini, but should pass for OpenAI
|
|
871
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain', channel: '#art', model: 'dall-e-3' }, ctx, makeImagegenCtx({ apiKey: 'openai-key' }));
|
|
872
|
+
expect(result.ok).toBe(true);
|
|
873
|
+
expect(fetch).toHaveBeenCalled();
|
|
874
|
+
});
|
|
875
|
+
it('preserves fallback behavior when model is omitted (Gemini default)', async () => {
|
|
876
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeGeminiNativeSuccessResponse()));
|
|
877
|
+
const ch = makeMockChannel({ id: 'origin-ch', name: 'general' });
|
|
878
|
+
const ctx = makeCtx([ch]);
|
|
879
|
+
const result = await executeImagegenAction({ type: 'generateImage', prompt: 'A mountain' }, ctx, makeImagegenCtx({ geminiApiKey: 'gemini-key' }));
|
|
880
|
+
expect(result.ok).toBe(true);
|
|
881
|
+
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('gemini-3.1-flash-image-preview:generateContent'), expect.anything());
|
|
882
|
+
});
|
|
883
|
+
});
|
|
772
884
|
describe('default model resolution', () => {
|
|
773
885
|
afterEach(() => {
|
|
774
886
|
vi.unstubAllGlobals();
|
package/dist/discord/actions.js
CHANGED
|
@@ -17,20 +17,12 @@ import { IMAGEGEN_ACTION_TYPES, executeImagegenAction, imagegenActionsPromptSect
|
|
|
17
17
|
import { VOICE_ACTION_TYPES, executeVoiceAction, voiceActionsPromptSection } from './actions-voice.js';
|
|
18
18
|
import { SPAWN_ACTION_TYPES, executeSpawnActions, spawnActionsPromptSection } from './actions-spawn.js';
|
|
19
19
|
import { describeDestructiveConfirmationRequirement } from './destructive-confirmation.js';
|
|
20
|
+
import { checkConfigAuthorization } from './action-dispatcher.js';
|
|
20
21
|
import { computeMarkdownCodeRanges } from './markdown-code-ranges.js';
|
|
21
22
|
import { parseCapsuleBlock } from './capsule.js';
|
|
22
23
|
export { computeMarkdownCodeRanges } from './markdown-code-ranges.js';
|
|
24
|
+
export { withoutRequesterGatedActionFlags } from './action-flags.js';
|
|
23
25
|
export const REQUESTER_MEMBER_DENY_ALL = { __requesterDenyAll: true };
|
|
24
|
-
export function withoutRequesterGatedActionFlags(flags) {
|
|
25
|
-
return {
|
|
26
|
-
...flags,
|
|
27
|
-
channels: false,
|
|
28
|
-
messaging: false,
|
|
29
|
-
guild: false,
|
|
30
|
-
moderation: false,
|
|
31
|
-
polls: false,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
26
|
import { appendOutsideFence } from './output-utils.js';
|
|
35
27
|
function buildImagegenSetupRequiredStub() {
|
|
36
28
|
return [
|
|
@@ -372,6 +364,12 @@ export async function executeDiscordActions(actions, ctx, log, subs) {
|
|
|
372
364
|
results.push(result);
|
|
373
365
|
continue;
|
|
374
366
|
}
|
|
367
|
+
// Fail-fast: deny config-mutating actions from unauthorized requesters.
|
|
368
|
+
const configAuthResult = checkConfigAuthorization(action.type, ctx.requesterId, ctx.allowUserIds);
|
|
369
|
+
if (configAuthResult) {
|
|
370
|
+
results.push(configAuthResult);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
375
373
|
if (CHANNEL_ACTION_TYPES.has(action.type)) {
|
|
376
374
|
result = await executeChannelAction(action, ctx, requesterMember);
|
|
377
375
|
}
|
|
@@ -514,6 +512,23 @@ export function buildDisplayResultLines(actions, results) {
|
|
|
514
512
|
export function buildAllResultLines(results) {
|
|
515
513
|
return results.map((r) => r.ok ? `Done: ${r.summary}` : `Failed: ${r.error}`);
|
|
516
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Cap a single result line to approximately `maxChars` characters.
|
|
517
|
+
* If truncated, appends a visible `...[truncated]` suffix.
|
|
518
|
+
*/
|
|
519
|
+
export function capResultLine(line, maxChars = 1500) {
|
|
520
|
+
if (line.length <= maxChars)
|
|
521
|
+
return line;
|
|
522
|
+
return `${line.slice(0, maxChars)}...[truncated]`;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Build result lines for follow-up prompts with per-line length capping.
|
|
526
|
+
* Each line is capped at `maxChars` characters to prevent oversized payloads
|
|
527
|
+
* from crowding out reasoning and action blocks in follow-up prompts.
|
|
528
|
+
*/
|
|
529
|
+
export function buildCappedResultLines(results, maxChars = 1500) {
|
|
530
|
+
return buildAllResultLines(results).map((line) => capResultLine(line, maxChars));
|
|
531
|
+
}
|
|
517
532
|
/**
|
|
518
533
|
* Append display result lines to body text, automatically closing any
|
|
519
534
|
* unclosed fenced code block so the results render outside the block.
|
|
@@ -44,3 +44,14 @@ export function isTrustedBot(allow, botId) {
|
|
|
44
44
|
return false;
|
|
45
45
|
return allow.has(botId);
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Check whether a requester is authorized for config-mutating actions.
|
|
49
|
+
* Fail closed: missing or empty allowlist, or missing requesterId, denies.
|
|
50
|
+
*/
|
|
51
|
+
export function isConfigAuthorized(allowUserIds, requesterId) {
|
|
52
|
+
if (!requesterId)
|
|
53
|
+
return false;
|
|
54
|
+
if (!allowUserIds || allowUserIds.size === 0)
|
|
55
|
+
return false;
|
|
56
|
+
return allowUserIds.has(requesterId);
|
|
57
|
+
}
|
|
@@ -27,6 +27,7 @@ vi.mock('./actions.js', () => ({
|
|
|
27
27
|
})),
|
|
28
28
|
buildDisplayResultLines: vi.fn(() => ['Succeeded: general, random']),
|
|
29
29
|
buildAllResultLines: vi.fn(() => ['Succeeded: general, random']),
|
|
30
|
+
buildCappedResultLines: vi.fn(() => ['Succeeded: general, random']),
|
|
30
31
|
appendActionResults: vi.fn((body) => `${body}\n> general, random`),
|
|
31
32
|
withoutRequesterGatedActionFlags: vi.fn((flags) => flags),
|
|
32
33
|
}));
|
|
@@ -6,7 +6,7 @@ import { isAllowlisted, isTrustedBot } from './allowlist.js';
|
|
|
6
6
|
import { KeyedQueue } from '../group-queue.js';
|
|
7
7
|
import { ensureIndexedDiscordChannelContext, resolveDiscordChannelContext } from './channel-context.js';
|
|
8
8
|
import { discordSessionKey } from './session-key.js';
|
|
9
|
-
import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildDisplayResultLines,
|
|
9
|
+
import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildDisplayResultLines, buildCappedResultLines, appendActionResults, withoutRequesterGatedActionFlags } from './actions.js';
|
|
10
10
|
import { shouldTriggerFollowUp } from './action-categories.js';
|
|
11
11
|
import { countPinnedMessages, normalizePinnedMessages } from './pinned-message-utils.js';
|
|
12
12
|
import { buildForgeCompletionWatchdogDetail, buildForgeCrashWatchdogDetail, buildForgePostProcessingWatchdogDetail, } from './actions-forge.js';
|
|
@@ -2264,6 +2264,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2264
2264
|
guild: msg.guild,
|
|
2265
2265
|
client: msg.client,
|
|
2266
2266
|
requesterId: msg.author.id,
|
|
2267
|
+
allowUserIds: params.allowUserIds,
|
|
2267
2268
|
channelId: msg.channelId,
|
|
2268
2269
|
messageId: msg.id,
|
|
2269
2270
|
threadParentId,
|
|
@@ -2751,6 +2752,8 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
2751
2752
|
useNativeTextFallback: runtimeSupportsNativeThinkingStream(params.runtime.id),
|
|
2752
2753
|
});
|
|
2753
2754
|
hadTextFinal = false;
|
|
2755
|
+
let responseTruncated = false;
|
|
2756
|
+
let responseFinishReason;
|
|
2754
2757
|
let currentFollowUpToken = null;
|
|
2755
2758
|
let currentFollowUpRunId = null;
|
|
2756
2759
|
// On follow-up iterations, send a new placeholder message.
|
|
@@ -3175,6 +3178,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
3175
3178
|
else if (evt.type === 'image_data') {
|
|
3176
3179
|
collectedImages.push(evt.image);
|
|
3177
3180
|
}
|
|
3181
|
+
else if (evt.type === 'finish_metadata') {
|
|
3182
|
+
responseTruncated = evt.truncated;
|
|
3183
|
+
responseFinishReason = evt.finishReason;
|
|
3184
|
+
}
|
|
3178
3185
|
}
|
|
3179
3186
|
else {
|
|
3180
3187
|
// Flat mode: stream text/final directly and append runtime signals.
|
|
@@ -3212,6 +3219,10 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
3212
3219
|
else if (evt.type === 'image_data') {
|
|
3213
3220
|
collectedImages.push(evt.image);
|
|
3214
3221
|
}
|
|
3222
|
+
else if (evt.type === 'finish_metadata') {
|
|
3223
|
+
responseTruncated = evt.truncated;
|
|
3224
|
+
responseFinishReason = evt.finishReason;
|
|
3225
|
+
}
|
|
3215
3226
|
}
|
|
3216
3227
|
}
|
|
3217
3228
|
}
|
|
@@ -3289,6 +3300,7 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
3289
3300
|
guild: msg.guild,
|
|
3290
3301
|
client: msg.client,
|
|
3291
3302
|
requesterId: !isBotMessage ? msg.author.id : undefined,
|
|
3303
|
+
allowUserIds: params.allowUserIds,
|
|
3292
3304
|
channelId: msg.channelId,
|
|
3293
3305
|
messageId: msg.id,
|
|
3294
3306
|
threadParentId,
|
|
@@ -3441,14 +3453,31 @@ export function createMessageCreateHandler(params, queue, statusRef) {
|
|
|
3441
3453
|
if (shouldQueueFollowUp) {
|
|
3442
3454
|
const token = buildFollowUpToken();
|
|
3443
3455
|
const failureRetryPlaceholder = buildFailureRetryPlaceholder(actions, actionResults);
|
|
3444
|
-
const followUpLines =
|
|
3456
|
+
const followUpLines = buildCappedResultLines(actionResults);
|
|
3445
3457
|
const followUpSuffix = failureRetryPlaceholder
|
|
3446
3458
|
? `One or more actions failed. If you retry, explicitly tell the user what failed and whether the retry succeeded or failed. Do not announce success before the action confirms it.`
|
|
3447
3459
|
: `Continue your analysis based on these results. If you need additional information, you may emit further query actions.`;
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3460
|
+
// Build the follow-up prompt with original request context and truncation awareness.
|
|
3461
|
+
const followUpParts = [];
|
|
3462
|
+
// Original request summary so a reset session knows what task it is continuing.
|
|
3463
|
+
const originalRequest = userText.trim();
|
|
3464
|
+
if (originalRequest) {
|
|
3465
|
+
const cappedRequest = originalRequest.length > 300
|
|
3466
|
+
? `${originalRequest.slice(0, 300)}...[truncated]`
|
|
3467
|
+
: originalRequest;
|
|
3468
|
+
followUpParts.push(`[Original request] ${cappedRequest}`);
|
|
3469
|
+
}
|
|
3470
|
+
// Truncation notice when the previous response was cut off by output limits.
|
|
3471
|
+
if (responseTruncated) {
|
|
3472
|
+
const reasonDetail = responseFinishReason ? ` (finishReason: ${responseFinishReason})` : '';
|
|
3473
|
+
followUpParts.push(`[Truncation notice] Your previous response was cut off by output token limits${reasonDetail}. ` +
|
|
3474
|
+
`Your earlier output may have ended before final reasoning or action blocks completed. ` +
|
|
3475
|
+
`Review the action results below and continue from where you left off.`);
|
|
3476
|
+
}
|
|
3477
|
+
followUpParts.push(`[Auto-follow-up] Your previous response included Discord actions. Here are the results:\n\n` +
|
|
3478
|
+
followUpLines.join('\n') +
|
|
3479
|
+
`\n\n${followUpSuffix}`);
|
|
3480
|
+
currentPrompt = followUpParts.join('\n\n');
|
|
3452
3481
|
const followUpActionSection = buildTieredDiscordActionsPromptSection(actionFlags, params.botDisplayName);
|
|
3453
3482
|
currentPrompt += '\n\n---\n' + followUpActionSection.prompt;
|
|
3454
3483
|
const pendingLine = buildFollowUpLifecycleLine(token, 'pending');
|
|
@@ -1736,6 +1736,54 @@ function hashFileContent(filePath) {
|
|
|
1736
1736
|
return '';
|
|
1737
1737
|
}
|
|
1738
1738
|
}
|
|
1739
|
+
/** Returns the current git branch name, or null if detached/unavailable. */
|
|
1740
|
+
export function gitCurrentBranch(cwd) {
|
|
1741
|
+
try {
|
|
1742
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1743
|
+
cwd, env: localGitEnv(), encoding: 'utf-8', stdio: 'pipe',
|
|
1744
|
+
}).trim() || null;
|
|
1745
|
+
}
|
|
1746
|
+
catch {
|
|
1747
|
+
return null;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Validates that a staged commit won't catastrophically delete most of the repo.
|
|
1752
|
+
* Call after `git add` but before `git commit`.
|
|
1753
|
+
*
|
|
1754
|
+
* Returns null if safe, or an error message string if the commit should be rejected.
|
|
1755
|
+
*/
|
|
1756
|
+
export function validateCommitSafety(cwd, preBranch) {
|
|
1757
|
+
const env = localGitEnv();
|
|
1758
|
+
// Check 1: Branch drift — did the branch change during phase execution?
|
|
1759
|
+
if (preBranch) {
|
|
1760
|
+
const currentBranch = gitCurrentBranch(cwd);
|
|
1761
|
+
if (currentBranch && currentBranch !== preBranch) {
|
|
1762
|
+
return `Branch changed during phase execution: expected "${preBranch}", got "${currentBranch}". ` +
|
|
1763
|
+
'This may indicate git init or git checkout --orphan was run.';
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
// Check 2: Deletion ratio — would this commit delete >50% of tracked files?
|
|
1767
|
+
try {
|
|
1768
|
+
const deletedRaw = execFileSync('git', ['diff', '--cached', '--diff-filter=D', '--name-only'], { cwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1769
|
+
const deletedCount = deletedRaw ? deletedRaw.split('\n').length : 0;
|
|
1770
|
+
if (deletedCount > 0) {
|
|
1771
|
+
// git ls-files returns the post-staging index (deletions already removed),
|
|
1772
|
+
// so add back deletedCount to get the original total.
|
|
1773
|
+
const remainingRaw = execFileSync('git', ['ls-files'], { cwd, env, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
1774
|
+
const remainingCount = remainingRaw ? remainingRaw.split('\n').length : 0;
|
|
1775
|
+
const originalTotal = remainingCount + deletedCount;
|
|
1776
|
+
if (originalTotal > 0 && deletedCount / originalTotal > 0.5) {
|
|
1777
|
+
return `Commit would delete ${deletedCount} of ${originalTotal} tracked files (${Math.round(deletedCount / originalTotal * 100)}%). ` +
|
|
1778
|
+
'This exceeds the 50% safety threshold.';
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
catch {
|
|
1783
|
+
// git command failed — skip this check rather than blocking
|
|
1784
|
+
}
|
|
1785
|
+
return null;
|
|
1786
|
+
}
|
|
1739
1787
|
// ---------------------------------------------------------------------------
|
|
1740
1788
|
// High-level runner
|
|
1741
1789
|
// ---------------------------------------------------------------------------
|
|
@@ -1816,6 +1864,7 @@ export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgres
|
|
|
1816
1864
|
writePhasesFile(phasesFilePath, allPhases);
|
|
1817
1865
|
// 6. Git snapshot (null = git command failed, skip modified-files tracking)
|
|
1818
1866
|
const preSnapshot = isGitAvailable ? gitDiffNames(opts.projectCwd) : null;
|
|
1867
|
+
const preBranch = isGitAvailable ? gitCurrentBranch(opts.projectCwd) : null;
|
|
1819
1868
|
// 7. Auto-revert on retry
|
|
1820
1869
|
if (phase.status === 'failed' && phase.modifiedFiles && phase.failureHashes && isGitAvailable && preSnapshot) {
|
|
1821
1870
|
// Note: we are re-reading phase from the old allPhases data (before status update).
|
|
@@ -2111,6 +2160,17 @@ export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgres
|
|
|
2111
2160
|
try {
|
|
2112
2161
|
const env = localGitEnv();
|
|
2113
2162
|
execFileSync('git', ['add', ...modifiedFiles], { cwd: opts.projectCwd, env, stdio: 'pipe' });
|
|
2163
|
+
// Safety gate: validate commit won't catastrophically delete files
|
|
2164
|
+
const safetyError = validateCommitSafety(opts.projectCwd, preBranch);
|
|
2165
|
+
if (safetyError) {
|
|
2166
|
+
execFileSync('git', ['reset'], { cwd: opts.projectCwd, env, stdio: 'pipe' });
|
|
2167
|
+
opts.log?.error({ phase: phase.id, safetyError }, 'plan-manager: commit blocked by safety check');
|
|
2168
|
+
// Mark phase as failed — a catastrophic deletion shouldn't graduate
|
|
2169
|
+
allPhases = updatePhaseStatus(allPhases, phase.id, 'failed', undefined, safetyError, null);
|
|
2170
|
+
writePhasesFile(phasesFilePath, allPhases);
|
|
2171
|
+
const failedPhase = allPhases.phases.find((p) => p.id === phase.id);
|
|
2172
|
+
return { result: 'failed', phase: failedPhase, output: '', error: safetyError };
|
|
2173
|
+
}
|
|
2114
2174
|
const commitMsg = `${allPhases.planId} ${phase.id}: ${phase.title}`;
|
|
2115
2175
|
execFileSync('git', ['commit', '-m', commitMsg], { cwd: opts.projectCwd, env, stdio: 'pipe' });
|
|
2116
2176
|
// Capture commit hash
|
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { execSync } from 'node:child_process';
|
|
8
|
-
import { computeAuditConvergenceSignature, computePlanHash, extractFilePaths, groupFiles, extractChangeSpec, decomposePlan, serializePhases, deserializePhases, getNextPhase, selectRunnablePhase, resequenceKeepingDone, validatePhaseDependencies, updatePhaseStatus, checkStaleness, buildPhasePrompt, buildAuditFixPrompt, buildPostRunSummary, extractObjective, resolveProjectCwd, resolveContextFilePath, serializePhasesStateJson, deserializePhasesStateJson, writePhasesFile, readPhasesFile, executePhase, runNextPhase, } from './plan-manager.js';
|
|
8
|
+
import { computeAuditConvergenceSignature, computePlanHash, extractFilePaths, groupFiles, extractChangeSpec, decomposePlan, serializePhases, deserializePhases, getNextPhase, selectRunnablePhase, resequenceKeepingDone, validatePhaseDependencies, updatePhaseStatus, checkStaleness, buildPhasePrompt, buildAuditFixPrompt, buildPostRunSummary, extractObjective, resolveProjectCwd, resolveContextFilePath, serializePhasesStateJson, deserializePhasesStateJson, writePhasesFile, readPhasesFile, executePhase, runNextPhase, gitCurrentBranch, validateCommitSafety, } from './plan-manager.js';
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
// Helpers
|
|
11
11
|
// ---------------------------------------------------------------------------
|
|
@@ -3406,3 +3406,89 @@ describe('buildPostRunSummary', () => {
|
|
|
3406
3406
|
]);
|
|
3407
3407
|
});
|
|
3408
3408
|
});
|
|
3409
|
+
// ---------------------------------------------------------------------------
|
|
3410
|
+
// gitCurrentBranch / validateCommitSafety
|
|
3411
|
+
// ---------------------------------------------------------------------------
|
|
3412
|
+
describe('gitCurrentBranch', () => {
|
|
3413
|
+
let tmpDir;
|
|
3414
|
+
beforeEach(async () => {
|
|
3415
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-safety-'));
|
|
3416
|
+
const gitEnv = { ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined };
|
|
3417
|
+
execSync('git init', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
3418
|
+
execSync('git config user.email "test@test.com"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
3419
|
+
execSync('git config user.name "Test"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
3420
|
+
fsSync.writeFileSync(path.join(tmpDir, 'README.md'), 'test');
|
|
3421
|
+
execSync('git add . && git commit -m "init"', { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
3422
|
+
});
|
|
3423
|
+
afterEach(async () => {
|
|
3424
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
3425
|
+
});
|
|
3426
|
+
it('returns the current branch name', () => {
|
|
3427
|
+
const branch = gitCurrentBranch(tmpDir);
|
|
3428
|
+
// Default branch is typically "main" or "master"
|
|
3429
|
+
expect(typeof branch).toBe('string');
|
|
3430
|
+
expect(branch.length).toBeGreaterThan(0);
|
|
3431
|
+
});
|
|
3432
|
+
it('returns null for non-git directory', async () => {
|
|
3433
|
+
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), 'non-git-'));
|
|
3434
|
+
try {
|
|
3435
|
+
expect(gitCurrentBranch(nonGit)).toBeNull();
|
|
3436
|
+
}
|
|
3437
|
+
finally {
|
|
3438
|
+
await fs.rm(nonGit, { recursive: true, force: true });
|
|
3439
|
+
}
|
|
3440
|
+
});
|
|
3441
|
+
});
|
|
3442
|
+
describe('validateCommitSafety', () => {
|
|
3443
|
+
let tmpDir;
|
|
3444
|
+
const gitEnv = () => ({ ...process.env, GIT_DIR: undefined, GIT_WORK_TREE: undefined });
|
|
3445
|
+
beforeEach(async () => {
|
|
3446
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'commit-safety-'));
|
|
3447
|
+
execSync('git init', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3448
|
+
execSync('git config user.email "test@test.com"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3449
|
+
execSync('git config user.name "Test"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3450
|
+
// Create a repo with enough files to trigger ratio checks
|
|
3451
|
+
for (let i = 0; i < 20; i++) {
|
|
3452
|
+
fsSync.writeFileSync(path.join(tmpDir, `file-${i}.txt`), `content-${i}`);
|
|
3453
|
+
}
|
|
3454
|
+
execSync('git add . && git commit -m "init with 20 files"', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3455
|
+
});
|
|
3456
|
+
afterEach(async () => {
|
|
3457
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
3458
|
+
});
|
|
3459
|
+
it('returns null for safe commit (no deletions)', () => {
|
|
3460
|
+
// Stage a small change
|
|
3461
|
+
fsSync.writeFileSync(path.join(tmpDir, 'file-0.txt'), 'updated');
|
|
3462
|
+
execSync('git add file-0.txt', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3463
|
+
const branch = gitCurrentBranch(tmpDir);
|
|
3464
|
+
expect(validateCommitSafety(tmpDir, branch)).toBeNull();
|
|
3465
|
+
});
|
|
3466
|
+
it('rejects commit that deletes >50% of files', () => {
|
|
3467
|
+
// Delete 15 of 20 files (75%)
|
|
3468
|
+
for (let i = 0; i < 15; i++) {
|
|
3469
|
+
fsSync.unlinkSync(path.join(tmpDir, `file-${i}.txt`));
|
|
3470
|
+
}
|
|
3471
|
+
execSync('git add -A', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3472
|
+
const branch = gitCurrentBranch(tmpDir);
|
|
3473
|
+
const result = validateCommitSafety(tmpDir, branch);
|
|
3474
|
+
expect(result).not.toBeNull();
|
|
3475
|
+
expect(result).toContain('safety threshold');
|
|
3476
|
+
});
|
|
3477
|
+
it('allows commit that deletes <50% of files', () => {
|
|
3478
|
+
// Delete 5 of 20 files (25%)
|
|
3479
|
+
for (let i = 0; i < 5; i++) {
|
|
3480
|
+
fsSync.unlinkSync(path.join(tmpDir, `file-${i}.txt`));
|
|
3481
|
+
}
|
|
3482
|
+
execSync('git add -A', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3483
|
+
const branch = gitCurrentBranch(tmpDir);
|
|
3484
|
+
expect(validateCommitSafety(tmpDir, branch)).toBeNull();
|
|
3485
|
+
});
|
|
3486
|
+
it('rejects when branch changed', () => {
|
|
3487
|
+
// Simulate branch drift by passing a different preBranch
|
|
3488
|
+
fsSync.writeFileSync(path.join(tmpDir, 'file-0.txt'), 'updated');
|
|
3489
|
+
execSync('git add file-0.txt', { cwd: tmpDir, env: gitEnv(), stdio: 'pipe' });
|
|
3490
|
+
const result = validateCommitSafety(tmpDir, 'some-other-branch');
|
|
3491
|
+
expect(result).not.toBeNull();
|
|
3492
|
+
expect(result).toContain('Branch changed');
|
|
3493
|
+
});
|
|
3494
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consistent error message for unauthorized config mutation attempts.
|
|
3
|
+
* Distinct from validation errors so callers can distinguish auth failures.
|
|
4
|
+
*/
|
|
5
|
+
export const CONFIG_UNAUTHORIZED_ERROR = 'Unauthorized: only allowlisted users can change bot configuration. This request was denied.';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Gemini model-identifier validation.
|
|
3
|
+
*
|
|
4
|
+
* Every code path that interpolates a model value into a Gemini REST URL
|
|
5
|
+
* (.../models/${model}:predict, :generateContent, :streamGenerateContent)
|
|
6
|
+
* must call {@link validateGeminiModelId} **before** constructing the URL.
|
|
7
|
+
*
|
|
8
|
+
* Only alphanumerics, hyphens, dots, and underscores are legal in a Gemini
|
|
9
|
+
* model path segment. Anything else (slashes, colons, percent-encoding,
|
|
10
|
+
* whitespace …) could alter the request URL.
|
|
11
|
+
*/
|
|
12
|
+
const GEMINI_MODEL_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
13
|
+
export function validateGeminiModelId(model) {
|
|
14
|
+
if (GEMINI_MODEL_RE.test(model))
|
|
15
|
+
return { ok: true };
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
error: `invalid Gemini model identifier "${model}"`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -482,6 +482,7 @@ export function createCliRuntime(strategy, opts) {
|
|
|
482
482
|
let mergedStdout = '';
|
|
483
483
|
let merged = '';
|
|
484
484
|
let resultText = '';
|
|
485
|
+
let lastStopReason;
|
|
485
486
|
let inToolUse = false;
|
|
486
487
|
const stdoutLineBuf = new LineBuffer();
|
|
487
488
|
let stderrBuffered = '';
|
|
@@ -556,6 +557,11 @@ export function createCliRuntime(strategy, opts) {
|
|
|
556
557
|
if (evt) {
|
|
557
558
|
parsedJsonLineCount++;
|
|
558
559
|
lastJsonEventType = getJsonLineEventType(evt) ?? null;
|
|
560
|
+
// Capture stop_reason from result events for finish_metadata
|
|
561
|
+
const evtObj = evt;
|
|
562
|
+
if (typeof evtObj.stop_reason === 'string' && evtObj.stop_reason) {
|
|
563
|
+
lastStopReason = evtObj.stop_reason;
|
|
564
|
+
}
|
|
559
565
|
}
|
|
560
566
|
else {
|
|
561
567
|
unparsableStdoutLineCount++;
|
|
@@ -916,6 +922,12 @@ export function createCliRuntime(strategy, opts) {
|
|
|
916
922
|
emittedUserOutput = true;
|
|
917
923
|
push({ type: 'text_final', text: final });
|
|
918
924
|
}
|
|
925
|
+
const cliTruncated = lastStopReason === 'max_tokens' || lastStopReason === 'length';
|
|
926
|
+
push({
|
|
927
|
+
type: 'finish_metadata',
|
|
928
|
+
truncated: cliTruncated,
|
|
929
|
+
...(lastStopReason ? { finishReason: lastStopReason } : {}),
|
|
930
|
+
});
|
|
919
931
|
push({ type: 'done' });
|
|
920
932
|
finished = true;
|
|
921
933
|
wake();
|
|
@@ -243,6 +243,7 @@ describe('createCliRuntime', () => {
|
|
|
243
243
|
}));
|
|
244
244
|
expect(events).toEqual([
|
|
245
245
|
{ type: 'text_final', text: '' },
|
|
246
|
+
{ type: 'finish_metadata', truncated: false },
|
|
246
247
|
{ type: 'done' },
|
|
247
248
|
]);
|
|
248
249
|
});
|
|
@@ -261,6 +262,7 @@ describe('createCliRuntime', () => {
|
|
|
261
262
|
}));
|
|
262
263
|
expect(events).toEqual([
|
|
263
264
|
{ type: 'text_final', text: '' },
|
|
265
|
+
{ type: 'finish_metadata', truncated: false },
|
|
264
266
|
{ type: 'done' },
|
|
265
267
|
]);
|
|
266
268
|
});
|
|
@@ -1687,6 +1687,12 @@ function summarizeTraceEvent(event) {
|
|
|
1687
1687
|
type: event.type,
|
|
1688
1688
|
mediaType: event.image.mediaType,
|
|
1689
1689
|
};
|
|
1690
|
+
case 'finish_metadata':
|
|
1691
|
+
return {
|
|
1692
|
+
type: event.type,
|
|
1693
|
+
truncated: event.truncated,
|
|
1694
|
+
...(event.finishReason ? { finishReason: event.finishReason } : {}),
|
|
1695
|
+
};
|
|
1690
1696
|
case 'done':
|
|
1691
1697
|
return { type: event.type };
|
|
1692
1698
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Auth via GEMINI_API_KEY header. Zero startup overhead vs. the CLI adapter.
|
|
4
4
|
import { splitSystemPrompt } from './openai-compat.js';
|
|
5
5
|
import { createRuntimeErrorEvent } from './runtime-failure.js';
|
|
6
|
+
import { validateGeminiModelId } from '../gemini-model-validation.js';
|
|
6
7
|
/** Extract the data payload from an SSE line, or undefined if not a data line. */
|
|
7
8
|
function parseSSEData(line) {
|
|
8
9
|
const trimmed = line.trim();
|
|
@@ -15,6 +16,15 @@ function parseSSEData(line) {
|
|
|
15
16
|
return undefined;
|
|
16
17
|
}
|
|
17
18
|
export function createGeminiRestRuntime(opts) {
|
|
19
|
+
// Validate defaultModel eagerly so configuration errors surface at startup,
|
|
20
|
+
// not on the first request. Empty string is allowed (caller must always
|
|
21
|
+
// supply params.model); non-empty values must pass the model-ID check.
|
|
22
|
+
if (opts.defaultModel) {
|
|
23
|
+
const check = validateGeminiModelId(opts.defaultModel);
|
|
24
|
+
if (!check.ok) {
|
|
25
|
+
throw new Error(`gemini-rest: invalid defaultModel: ${check.error}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
18
28
|
const capabilities = new Set(['streaming_text']);
|
|
19
29
|
const baseUrl = opts.baseUrl ?? 'https://generativelanguage.googleapis.com/v1beta';
|
|
20
30
|
return {
|
|
@@ -24,6 +34,12 @@ export function createGeminiRestRuntime(opts) {
|
|
|
24
34
|
invoke(params) {
|
|
25
35
|
return (async function* () {
|
|
26
36
|
const model = params.model || opts.defaultModel;
|
|
37
|
+
const modelCheck = validateGeminiModelId(model);
|
|
38
|
+
if (!modelCheck.ok) {
|
|
39
|
+
yield createRuntimeErrorEvent(`gemini-rest: ${modelCheck.error}`);
|
|
40
|
+
yield { type: 'done' };
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
27
43
|
const url = `${baseUrl}/models/${model}:streamGenerateContent?alt=sse`;
|
|
28
44
|
const controller = new AbortController();
|
|
29
45
|
let timer;
|
|
@@ -136,6 +152,12 @@ export function createGeminiRestRuntime(opts) {
|
|
|
136
152
|
opts.log?.debug({ candidate: lastCandidate }, 'gemini-rest: full candidate on empty response');
|
|
137
153
|
}
|
|
138
154
|
yield { type: 'text_final', text: accumulated };
|
|
155
|
+
const geminiTruncated = lastFinishReason === 'MAX_TOKENS' || lastFinishReason === 'STOP_LIMIT';
|
|
156
|
+
yield {
|
|
157
|
+
type: 'finish_metadata',
|
|
158
|
+
truncated: geminiTruncated,
|
|
159
|
+
...(lastFinishReason ? { finishReason: lastFinishReason } : {}),
|
|
160
|
+
};
|
|
139
161
|
yield { type: 'done' };
|
|
140
162
|
}
|
|
141
163
|
catch (err) {
|
|
@@ -192,6 +192,70 @@ describe('Gemini REST runtime adapter', () => {
|
|
|
192
192
|
await collectEvents(runtime.invoke({ prompt: 'test', model: '', cwd: '/tmp' }));
|
|
193
193
|
expect(log.debug).toHaveBeenCalledWith({ candidate }, 'gemini-rest: full candidate on empty response');
|
|
194
194
|
});
|
|
195
|
+
it('rejects model with path traversal characters', async () => {
|
|
196
|
+
globalThis.fetch = vi.fn();
|
|
197
|
+
const runtime = createGeminiRestRuntime({
|
|
198
|
+
apiKey: 'test-key',
|
|
199
|
+
defaultModel: 'gemini-2.5-flash',
|
|
200
|
+
});
|
|
201
|
+
const events = await collectEvents(runtime.invoke({ prompt: 'test', model: '../evil/path', cwd: '/tmp' }));
|
|
202
|
+
const error = events.find((e) => e.type === 'error');
|
|
203
|
+
expect(error).toBeDefined();
|
|
204
|
+
expect(error.message).toContain('invalid Gemini model identifier');
|
|
205
|
+
expect(events.at(-1)?.type).toBe('done');
|
|
206
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
it('rejects model with colons', async () => {
|
|
209
|
+
globalThis.fetch = vi.fn();
|
|
210
|
+
const runtime = createGeminiRestRuntime({
|
|
211
|
+
apiKey: 'test-key',
|
|
212
|
+
defaultModel: 'gemini-2.5-flash',
|
|
213
|
+
});
|
|
214
|
+
const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'model:inject', cwd: '/tmp' }));
|
|
215
|
+
const error = events.find((e) => e.type === 'error');
|
|
216
|
+
expect(error).toBeDefined();
|
|
217
|
+
expect(error.message).toContain('invalid Gemini model identifier');
|
|
218
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
it('rejects model with slashes', async () => {
|
|
221
|
+
globalThis.fetch = vi.fn();
|
|
222
|
+
const runtime = createGeminiRestRuntime({
|
|
223
|
+
apiKey: 'test-key',
|
|
224
|
+
defaultModel: 'gemini-2.5-flash',
|
|
225
|
+
});
|
|
226
|
+
const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'models/evil', cwd: '/tmp' }));
|
|
227
|
+
const error = events.find((e) => e.type === 'error');
|
|
228
|
+
expect(error).toBeDefined();
|
|
229
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
it('rejects empty model when defaultModel is also empty', async () => {
|
|
232
|
+
globalThis.fetch = vi.fn();
|
|
233
|
+
const runtime = createGeminiRestRuntime({
|
|
234
|
+
apiKey: 'test-key',
|
|
235
|
+
defaultModel: '',
|
|
236
|
+
});
|
|
237
|
+
const events = await collectEvents(runtime.invoke({ prompt: 'test', model: '', cwd: '/tmp' }));
|
|
238
|
+
const error = events.find((e) => e.type === 'error');
|
|
239
|
+
expect(error).toBeDefined();
|
|
240
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
it('accepts valid model identifiers with dots and hyphens', async () => {
|
|
243
|
+
globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([makeGeminiSSEData('ok')]));
|
|
244
|
+
const runtime = createGeminiRestRuntime({
|
|
245
|
+
apiKey: 'test-key',
|
|
246
|
+
defaultModel: 'gemini-2.5-flash',
|
|
247
|
+
});
|
|
248
|
+
const events = await collectEvents(runtime.invoke({ prompt: 'test', model: 'gemini-2.5-pro-preview-05-06', cwd: '/tmp' }));
|
|
249
|
+
expect(events.find((e) => e.type === 'error')).toBeUndefined();
|
|
250
|
+
expect(events.find((e) => e.type === 'text_final')).toBeDefined();
|
|
251
|
+
expect(globalThis.fetch).toHaveBeenCalledOnce();
|
|
252
|
+
});
|
|
253
|
+
it('throws at construction time if defaultModel is invalid', () => {
|
|
254
|
+
expect(() => createGeminiRestRuntime({ apiKey: 'test-key', defaultModel: '../evil' })).toThrow('invalid defaultModel');
|
|
255
|
+
});
|
|
256
|
+
it('allows empty defaultModel at construction time', () => {
|
|
257
|
+
expect(() => createGeminiRestRuntime({ apiKey: 'test-key', defaultModel: '' })).not.toThrow();
|
|
258
|
+
});
|
|
195
259
|
it('supports custom baseUrl', async () => {
|
|
196
260
|
globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([makeGeminiSSEData('ok')]));
|
|
197
261
|
const runtime = createGeminiRestRuntime({
|
|
@@ -178,6 +178,12 @@ export function createOpenAICompatRuntime(opts) {
|
|
|
178
178
|
if (content)
|
|
179
179
|
yield { type: 'text_delta', text: content };
|
|
180
180
|
yield { type: 'text_final', text: content };
|
|
181
|
+
const toolLoopFinishReason = choice?.finish_reason;
|
|
182
|
+
yield {
|
|
183
|
+
type: 'finish_metadata',
|
|
184
|
+
truncated: toolLoopFinishReason === 'length',
|
|
185
|
+
...(toolLoopFinishReason ? { finishReason: toolLoopFinishReason } : {}),
|
|
186
|
+
};
|
|
181
187
|
yield { type: 'done' };
|
|
182
188
|
return;
|
|
183
189
|
}
|
|
@@ -235,6 +241,7 @@ export function createOpenAICompatRuntime(opts) {
|
|
|
235
241
|
...tokenField,
|
|
236
242
|
});
|
|
237
243
|
let accumulated = '';
|
|
244
|
+
let streamFinishReason;
|
|
238
245
|
const response = await fetchWithOpenAIBearerAuth({
|
|
239
246
|
url,
|
|
240
247
|
auth,
|
|
@@ -273,16 +280,25 @@ export function createOpenAICompatRuntime(opts) {
|
|
|
273
280
|
return false;
|
|
274
281
|
if (data === '[DONE]') {
|
|
275
282
|
yield { type: 'text_final', text: accumulated };
|
|
283
|
+
yield {
|
|
284
|
+
type: 'finish_metadata',
|
|
285
|
+
truncated: streamFinishReason === 'length',
|
|
286
|
+
...(streamFinishReason ? { finishReason: streamFinishReason } : {}),
|
|
287
|
+
};
|
|
276
288
|
yield { type: 'done' };
|
|
277
289
|
return true;
|
|
278
290
|
}
|
|
279
291
|
try {
|
|
280
292
|
const parsed = JSON.parse(data);
|
|
281
|
-
const
|
|
293
|
+
const choice = parsed?.choices?.[0];
|
|
294
|
+
const content = choice?.delta?.content;
|
|
282
295
|
if (content) {
|
|
283
296
|
accumulated += content;
|
|
284
297
|
yield { type: 'text_delta', text: content };
|
|
285
298
|
}
|
|
299
|
+
const chunkFinish = choice?.finish_reason;
|
|
300
|
+
if (chunkFinish)
|
|
301
|
+
streamFinishReason = chunkFinish;
|
|
286
302
|
}
|
|
287
303
|
catch {
|
|
288
304
|
// Skip unparseable lines
|
|
@@ -322,6 +338,11 @@ export function createOpenAICompatRuntime(opts) {
|
|
|
322
338
|
}
|
|
323
339
|
// Stream ended without [DONE] — emit what we have
|
|
324
340
|
yield { type: 'text_final', text: accumulated };
|
|
341
|
+
yield {
|
|
342
|
+
type: 'finish_metadata',
|
|
343
|
+
truncated: streamFinishReason === 'length',
|
|
344
|
+
...(streamFinishReason ? { finishReason: streamFinishReason } : {}),
|
|
345
|
+
};
|
|
325
346
|
yield { type: 'done' };
|
|
326
347
|
}
|
|
327
348
|
}
|
|
@@ -23,7 +23,9 @@ const TOOL_USE_NAME_LENGTH_GUIDANCE = 'Anthropic API error: MCP tool name too lo
|
|
|
23
23
|
* receives an explicit boundary at each phase boundary.
|
|
24
24
|
*/
|
|
25
25
|
export const PHASE_SAFETY_REMINDER = 'SAFETY (automated agent): Do not run rm -rf outside build artifact directories, ' +
|
|
26
|
-
'git push --force, git branch -D,
|
|
26
|
+
'git push --force, git branch -D, git init, git checkout --orphan, ' +
|
|
27
|
+
'git reset --hard <other-commit>, DROP TABLE, or chmod 777. ' +
|
|
28
|
+
'Do not switch branches or create new branches during implementation phases. ' +
|
|
27
29
|
'Do not write to .env, root-policy.ts, ~/.ssh/, or ~/.claude/ paths. ' +
|
|
28
30
|
'If a task requires any of these, report it instead of executing.';
|
|
29
31
|
const THINKING_PREVIEW_INTERVAL_MS = 3_000;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards against prototype-pollution via dynamic route parameters.
|
|
3
|
+
*
|
|
4
|
+
* When a URL segment (e.g. `/webhook/:source`) is used as a plain-object
|
|
5
|
+
* property name, reserved keys like `__proto__` can trigger prototype
|
|
6
|
+
* pollution or uncaught exceptions. Call this before any object lookup
|
|
7
|
+
* keyed by a route parameter.
|
|
8
|
+
*/
|
|
9
|
+
const RESERVED = new Set([
|
|
10
|
+
'__proto__',
|
|
11
|
+
'constructor',
|
|
12
|
+
'prototype',
|
|
13
|
+
]);
|
|
14
|
+
/** Returns `true` when `key` is a reserved Object property name. */
|
|
15
|
+
export function isReservedObjectKey(key) {
|
|
16
|
+
return RESERVED.has(key);
|
|
17
|
+
}
|
package/dist/webhook/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import http from 'node:http';
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import { executeCronJob } from '../cron/executor.js';
|
|
10
|
+
import { isUnsafeKey, stripUnsafeKeys } from '../config/safe-key.js';
|
|
10
11
|
import { sanitizeExternalContent } from '../sanitize-external.js';
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
// Shared constants + config types
|
|
@@ -33,7 +34,7 @@ export async function loadWebhookConfig(configPath) {
|
|
|
33
34
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
34
35
|
throw new Error('Webhook config must be a JSON object');
|
|
35
36
|
}
|
|
36
|
-
return parsed;
|
|
37
|
+
return stripUnsafeKeys(parsed);
|
|
37
38
|
}
|
|
38
39
|
let webhookJobCounter = 0;
|
|
39
40
|
function buildWebhookJob(source, src, bodyText, guildId) {
|
|
@@ -113,6 +114,10 @@ export async function startWebhookServer(opts = {}) {
|
|
|
113
114
|
respondWebhook(res, 400, 'Bad request');
|
|
114
115
|
return;
|
|
115
116
|
}
|
|
117
|
+
if (isUnsafeKey(source)) {
|
|
118
|
+
respondWebhook(res, 400, 'Bad request');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
116
121
|
const src = config[source];
|
|
117
122
|
if (!src) {
|
|
118
123
|
log?.warn({ source }, 'webhook:unknown source');
|
|
@@ -164,6 +164,21 @@ describe('startWebhookServer HTTP routing', () => {
|
|
|
164
164
|
const res = await makeRequest(port, { path: '/webhook/foo%ZZbar' });
|
|
165
165
|
expect(res.status).toBe(400);
|
|
166
166
|
});
|
|
167
|
+
it('returns 400 for __proto__ source (prototype pollution)', async () => {
|
|
168
|
+
const res = await makeRequest(port, { path: '/webhook/__proto__', body: '{}' });
|
|
169
|
+
expect(res.status).toBe(400);
|
|
170
|
+
expect(res.body.ok).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
it('returns 400 for constructor source (prototype pollution)', async () => {
|
|
173
|
+
const res = await makeRequest(port, { path: '/webhook/constructor', body: '{}' });
|
|
174
|
+
expect(res.status).toBe(400);
|
|
175
|
+
expect(res.body.ok).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
it('returns 400 for prototype source (prototype pollution)', async () => {
|
|
178
|
+
const res = await makeRequest(port, { path: '/webhook/prototype', body: '{}' });
|
|
179
|
+
expect(res.status).toBe(400);
|
|
180
|
+
expect(res.body.ok).toBe(false);
|
|
181
|
+
});
|
|
167
182
|
it('returns 404 for an unknown source', async () => {
|
|
168
183
|
const body = '{}';
|
|
169
184
|
const res = await makeRequest(port, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discoclaw",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Personal AI orchestrator that turns Discord into a persistent workspace",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"youtube-transcript-plus": "^1.2.0"
|
|
74
74
|
},
|
|
75
75
|
"simple-git-hooks": {
|
|
76
|
-
"pre-push": "pnpm build && pnpm test"
|
|
76
|
+
"pre-push": "bash scripts/pre-push-safety.sh && pnpm build && pnpm test"
|
|
77
77
|
},
|
|
78
78
|
"publishConfig": {
|
|
79
79
|
"access": "public",
|
|
@@ -114,6 +114,17 @@ Your training data has a cutoff date. Anything that could have changed recently
|
|
|
114
114
|
|
|
115
115
|
The cost of a quick web search is negligible. The cost of confidently declaring something doesn't exist -- when it dropped two days ago -- is your credibility.
|
|
116
116
|
|
|
117
|
+
## Git Safety Rules
|
|
118
|
+
|
|
119
|
+
These rules apply to all git operations, including freeform "commit and PR" flows:
|
|
120
|
+
|
|
121
|
+
- **Never** run `git init` in the project directory
|
|
122
|
+
- **Never** run `git checkout --orphan` — this replaces the entire repo tree
|
|
123
|
+
- **Never** run `git reset --hard` to a different commit without explicit user approval
|
|
124
|
+
- Before pushing, verify the diff is proportional to the task — a bug fix should not delete hundreds of files
|
|
125
|
+
- If the diff shows mass deletions unrelated to the task, **stop and report** instead of pushing
|
|
126
|
+
- Do not switch branches during implementation unless the task explicitly requires it
|
|
127
|
+
|
|
117
128
|
## Landing the Plane (Session Completion)
|
|
118
129
|
|
|
119
130
|
Work is complete only when `git push` succeeds — local-only work is stranded work. If push fails, resolve and retry.
|