kimaki 0.24.0 → 0.26.0
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/dist/agent-model.e2e.test.js +135 -3
- package/dist/analytics.test.js +30 -1
- package/dist/cli-commands/send.js +13 -3
- package/dist/cli-commands/task.js +26 -7
- package/dist/cli-runner.js +66 -70
- package/dist/cli-runner.test.js +10 -1
- package/dist/cli.js +5 -0
- package/dist/cloudflare-transcription.js +56 -0
- package/dist/commands/agent.js +87 -74
- package/dist/commands/model-variant.js +17 -1
- package/dist/commands/model.js +18 -0
- package/dist/commands/multioauth.js +169 -1
- package/dist/commands/queue.js +64 -0
- package/dist/commands/tasks.js +6 -0
- package/dist/commands/unset-model.js +5 -20
- package/dist/config.js +9 -0
- package/dist/database.js +65 -0
- package/dist/discord-bot.js +8 -6
- package/dist/discord-command-registration.js +17 -12
- package/dist/external-directory-permissions.e2e.test.js +82 -0
- package/dist/external-directory-project-deny.e2e.test.js +68 -0
- package/dist/format-tables.js +53 -5
- package/dist/format-tables.test.js +121 -0
- package/dist/interaction-handler.js +8 -6
- package/dist/ipc-tools-plugin.js +4 -3
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/message-formatting.js +84 -5
- package/dist/message-formatting.test.js +160 -2
- package/dist/message-preprocessing.js +2 -25
- package/dist/oauth-rotation-shared.js +7 -1
- package/dist/openai-auth-state.js +20 -0
- package/dist/opencode.js +126 -96
- package/dist/queue-advanced-e2e-setup.js +63 -27
- package/dist/queue-advanced-permissions-typing.e2e.test.js +4 -0
- package/dist/schema.js +19 -0
- package/dist/session-handler/event-stream-state.js +147 -0
- package/dist/session-handler/event-stream-state.test.js +437 -1
- package/dist/session-handler/thread-session-runtime.js +95 -57
- package/dist/store.js +1 -0
- package/dist/subagent-rate-limit-plugin.js +13 -2
- package/dist/system-message.js +18 -4
- package/dist/system-message.test.js +28 -4
- package/dist/task-runner.js +143 -12
- package/dist/task-schedule.js +12 -0
- package/dist/task-schedule.test.js +149 -2
- package/dist/voice-handler.js +37 -11
- package/dist/xai-auth-plugin.js +237 -0
- package/dist/xai-auth-state.js +233 -0
- package/dist/xai-auth-state.test.js +183 -0
- package/package.json +6 -6
- package/skills/profano/SKILL.md +5 -3
- package/skills/spiceflow/SKILL.md +24 -1
- package/src/agent-model.e2e.test.ts +172 -4
- package/src/analytics.test.ts +30 -1
- package/src/analytics.ts +1 -0
- package/src/cli-commands/send.ts +22 -3
- package/src/cli-commands/task.ts +32 -19
- package/src/cli-runner.test.ts +16 -1
- package/src/cli-runner.ts +88 -89
- package/src/cli.ts +11 -0
- package/src/cloudflare-transcription.ts +76 -0
- package/src/commands/agent.ts +120 -84
- package/src/commands/model-variant.ts +26 -0
- package/src/commands/model.ts +25 -0
- package/src/commands/multioauth.ts +195 -2
- package/src/commands/queue.ts +83 -0
- package/src/commands/tasks.ts +6 -0
- package/src/commands/unset-model.ts +5 -21
- package/src/config.ts +10 -0
- package/src/database.ts +124 -0
- package/src/discord-bot.ts +7 -6
- package/src/discord-command-registration.ts +22 -15
- package/src/external-directory-permissions.e2e.test.ts +99 -0
- package/src/external-directory-project-deny.e2e.test.ts +82 -0
- package/src/format-tables.test.ts +126 -0
- package/src/format-tables.ts +68 -5
- package/src/interaction-handler.ts +10 -5
- package/src/ipc-tools-plugin.ts +4 -3
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/message-formatting.test.ts +197 -2
- package/src/message-formatting.ts +98 -5
- package/src/message-preprocessing.ts +2 -37
- package/src/oauth-rotation-shared.ts +6 -1
- package/src/openai-auth-state.ts +19 -0
- package/src/opencode.ts +132 -111
- package/src/queue-advanced-e2e-setup.ts +78 -29
- package/src/queue-advanced-permissions-typing.e2e.test.ts +4 -0
- package/src/schema.sql +15 -0
- package/src/schema.ts +20 -0
- package/src/session-handler/event-stream-state.test.ts +490 -0
- package/src/session-handler/event-stream-state.ts +210 -0
- package/src/session-handler/model-utils.ts +1 -0
- package/src/session-handler/thread-runtime-state.ts +0 -4
- package/src/session-handler/thread-session-runtime.ts +108 -69
- package/src/store.ts +10 -0
- package/src/subagent-rate-limit-plugin.ts +12 -2
- package/src/system-message.test.ts +29 -4
- package/src/system-message.ts +20 -4
- package/src/task-runner.ts +168 -11
- package/src/task-schedule.test.ts +172 -2
- package/src/task-schedule.ts +23 -2
- package/src/voice-handler.ts +37 -10
- package/src/xai-auth-plugin.ts +302 -0
- package/src/xai-auth-state.test.ts +224 -0
- package/src/xai-auth-state.ts +293 -0
- package/dist/channel-reference-permissions.e2e.test.js +0 -87
- package/dist/commands/add-dir.js +0 -169
- package/dist/commands/add-dir.test.js +0 -126
- package/src/channel-reference-permissions.e2e.test.ts +0 -104
- package/src/commands/add-dir.test.ts +0 -154
- package/src/commands/add-dir.ts +0 -244
package/dist/config.js
CHANGED
|
@@ -70,6 +70,15 @@ export function setProjectsDir(dir) {
|
|
|
70
70
|
export function getPermissionTimeoutMs() {
|
|
71
71
|
return store.getState().permissionTimeoutMs;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether external directory access is restricted to the session working
|
|
75
|
+
* directory plus a few known-safe paths.
|
|
76
|
+
* Defaults to false: every directory is allowed and users protect specific
|
|
77
|
+
* folders with their own `deny`/`ask` rules in opencode.json.
|
|
78
|
+
*/
|
|
79
|
+
export function getRestrictExternalDirectories() {
|
|
80
|
+
return store.getState().restrictExternalDirectories;
|
|
81
|
+
}
|
|
73
82
|
const DEFAULT_LOCK_PORT = 29988;
|
|
74
83
|
/**
|
|
75
84
|
* Derive a lock port from the data directory path.
|
package/dist/database.js
CHANGED
|
@@ -95,6 +95,71 @@ export async function claimScheduledTaskRunning({ taskId, startedAt }) {
|
|
|
95
95
|
.returning({ id: schema.scheduled_tasks.id });
|
|
96
96
|
return countRows(rows) > 0;
|
|
97
97
|
}
|
|
98
|
+
export async function releaseScheduledTaskClaim(taskId) {
|
|
99
|
+
const db = await getDb();
|
|
100
|
+
await db.update(schema.scheduled_tasks)
|
|
101
|
+
.set({ status: 'planned', running_started_at: null })
|
|
102
|
+
.where(orm.and(orm.eq(schema.scheduled_tasks.id, taskId), orm.eq(schema.scheduled_tasks.status, 'running')));
|
|
103
|
+
}
|
|
104
|
+
export async function createScheduledTaskRun({ taskId, startedAt, }) {
|
|
105
|
+
const db = await getDb();
|
|
106
|
+
const [run] = await db.insert(schema.scheduled_task_runs)
|
|
107
|
+
.values({ scheduled_task_id: taskId, started_at: startedAt })
|
|
108
|
+
.returning({ id: schema.scheduled_task_runs.id });
|
|
109
|
+
if (!run)
|
|
110
|
+
throw new Error(`Failed to create run for scheduled task ${taskId}`);
|
|
111
|
+
return run.id;
|
|
112
|
+
}
|
|
113
|
+
export async function getActiveScheduledTaskRuns(taskId) {
|
|
114
|
+
const db = await getDb();
|
|
115
|
+
return db.query.scheduled_task_runs.findMany({
|
|
116
|
+
where: {
|
|
117
|
+
scheduled_task_id: taskId,
|
|
118
|
+
status: { in: ['pending', 'running'] },
|
|
119
|
+
},
|
|
120
|
+
orderBy: { started_at: 'desc' },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export async function setScheduledTaskRunThread({ runId, threadId, }) {
|
|
124
|
+
const db = await getDb();
|
|
125
|
+
await db.update(schema.scheduled_task_runs)
|
|
126
|
+
.set({ thread_id: threadId })
|
|
127
|
+
.where(orm.eq(schema.scheduled_task_runs.id, runId));
|
|
128
|
+
}
|
|
129
|
+
export async function startScheduledTaskRunSession({ runId, sessionId, projectDirectory, }) {
|
|
130
|
+
const db = await getDb();
|
|
131
|
+
await db.update(schema.scheduled_task_runs)
|
|
132
|
+
.set({ status: 'running', session_id: sessionId, project_directory: projectDirectory })
|
|
133
|
+
.where(orm.and(orm.eq(schema.scheduled_task_runs.id, runId), orm.eq(schema.scheduled_task_runs.status, 'pending')));
|
|
134
|
+
}
|
|
135
|
+
export async function completeScheduledTaskRunsForSession(sessionId) {
|
|
136
|
+
const db = await getDb();
|
|
137
|
+
const rows = await db.update(schema.scheduled_task_runs)
|
|
138
|
+
.set({ status: 'completed', completed_at: new Date() })
|
|
139
|
+
.where(orm.and(orm.eq(schema.scheduled_task_runs.session_id, sessionId), orm.inArray(schema.scheduled_task_runs.status, ['pending', 'running'])))
|
|
140
|
+
.returning({ id: schema.scheduled_task_runs.id });
|
|
141
|
+
return rows.length;
|
|
142
|
+
}
|
|
143
|
+
export async function failScheduledTaskRunsForSession({ sessionId, error, }) {
|
|
144
|
+
const db = await getDb();
|
|
145
|
+
const rows = await db.update(schema.scheduled_task_runs)
|
|
146
|
+
.set({ status: 'failed', completed_at: new Date(), error })
|
|
147
|
+
.where(orm.and(orm.eq(schema.scheduled_task_runs.session_id, sessionId), orm.inArray(schema.scheduled_task_runs.status, ['pending', 'running'])))
|
|
148
|
+
.returning({ id: schema.scheduled_task_runs.id });
|
|
149
|
+
return rows.length;
|
|
150
|
+
}
|
|
151
|
+
export async function failScheduledTaskRun({ runId, error, }) {
|
|
152
|
+
const db = await getDb();
|
|
153
|
+
await db.update(schema.scheduled_task_runs)
|
|
154
|
+
.set({ status: 'failed', completed_at: new Date(), error })
|
|
155
|
+
.where(orm.eq(schema.scheduled_task_runs.id, runId));
|
|
156
|
+
}
|
|
157
|
+
export async function finishScheduledTaskRun({ runId, status, }) {
|
|
158
|
+
const db = await getDb();
|
|
159
|
+
await db.update(schema.scheduled_task_runs)
|
|
160
|
+
.set({ status, completed_at: new Date() })
|
|
161
|
+
.where(orm.eq(schema.scheduled_task_runs.id, runId));
|
|
162
|
+
}
|
|
98
163
|
export async function recoverStaleRunningScheduledTasks({ staleBefore }) {
|
|
99
164
|
const db = await getDb();
|
|
100
165
|
const rows = await db.update(schema.scheduled_tasks)
|
package/dist/discord-bot.js
CHANGED
|
@@ -20,7 +20,7 @@ import { getFileAttachments, getTextAttachments, resolveMentions, } from './mess
|
|
|
20
20
|
import { extractBtwSuffix } from './btw-prefix-detection.js';
|
|
21
21
|
import { isVoiceAttachment } from './voice-attachment.js';
|
|
22
22
|
import { forkSessionToBtwThread } from './commands/btw.js';
|
|
23
|
-
import { extractQueueSuffix,
|
|
23
|
+
import { extractQueueSuffix, preprocessExistingThreadMessage, preprocessNewThreadMessage, } from './message-preprocessing.js';
|
|
24
24
|
import { cancelPendingActionButtons } from './commands/action-buttons.js';
|
|
25
25
|
import { cancelPendingQuestion, hasPendingQuestionForThread } from './commands/ask-question.js';
|
|
26
26
|
import { cancelPendingFileUpload } from './commands/file-upload.js';
|
|
@@ -142,11 +142,15 @@ function parseSessionStartSourceFromMarker(marker) {
|
|
|
142
142
|
if (typeof marker.scheduledTaskId !== 'number' ||
|
|
143
143
|
!Number.isInteger(marker.scheduledTaskId) ||
|
|
144
144
|
marker.scheduledTaskId < 1) {
|
|
145
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
scheduleKind: marker.scheduledKind,
|
|
147
|
+
scheduledTaskRunId: marker.scheduledTaskRunId,
|
|
148
|
+
};
|
|
146
149
|
}
|
|
147
150
|
return {
|
|
148
151
|
scheduleKind: marker.scheduledKind,
|
|
149
152
|
scheduledTaskId: marker.scheduledTaskId,
|
|
153
|
+
scheduledTaskRunId: marker.scheduledTaskRunId,
|
|
150
154
|
};
|
|
151
155
|
}
|
|
152
156
|
export async function createDiscordClient() {
|
|
@@ -632,6 +636,7 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
632
636
|
? {
|
|
633
637
|
scheduleKind: sessionStartSource.scheduleKind,
|
|
634
638
|
scheduledTaskId: sessionStartSource.scheduledTaskId,
|
|
639
|
+
scheduledTaskRunId: sessionStartSource.scheduledTaskRunId,
|
|
635
640
|
}
|
|
636
641
|
: undefined,
|
|
637
642
|
preprocess: () => {
|
|
@@ -1085,15 +1090,12 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
1085
1090
|
? {
|
|
1086
1091
|
scheduleKind: botThreadStartSource.scheduleKind,
|
|
1087
1092
|
scheduledTaskId: botThreadStartSource.scheduledTaskId,
|
|
1093
|
+
scheduledTaskRunId: botThreadStartSource.scheduledTaskRunId,
|
|
1088
1094
|
}
|
|
1089
1095
|
: undefined,
|
|
1090
1096
|
preprocess: async () => {
|
|
1091
|
-
const permissionRules = await getChannelReferencePermissionRules({
|
|
1092
|
-
message: starterMessage,
|
|
1093
|
-
});
|
|
1094
1097
|
return {
|
|
1095
1098
|
prompt,
|
|
1096
|
-
permissionRules,
|
|
1097
1099
|
mode: 'opencode',
|
|
1098
1100
|
...(fileAttachments.length > 0 && { images: fileAttachments }),
|
|
1099
1101
|
};
|
|
@@ -167,18 +167,6 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
167
167
|
})
|
|
168
168
|
.setDMPermission(false)
|
|
169
169
|
.toJSON(),
|
|
170
|
-
new SlashCommandBuilder()
|
|
171
|
-
.setName('add-dir')
|
|
172
|
-
.setDescription(truncateCommandDescription('Allow the current session to access an extra directory or * for all folders'))
|
|
173
|
-
.addStringOption((option) => {
|
|
174
|
-
option
|
|
175
|
-
.setName('directory')
|
|
176
|
-
.setDescription(truncateCommandDescription('Directory to allow, resolved from the current worktree. Use * for all folders'))
|
|
177
|
-
.setRequired(false);
|
|
178
|
-
return option;
|
|
179
|
-
})
|
|
180
|
-
.setDMPermission(false)
|
|
181
|
-
.toJSON(),
|
|
182
170
|
new SlashCommandBuilder()
|
|
183
171
|
.setName('abort')
|
|
184
172
|
.setDescription(truncateCommandDescription('Abort, stop, terminate, or cancel the current OpenCode request in this thread'))
|
|
@@ -226,6 +214,11 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
226
214
|
.setDescription(truncateCommandDescription('Set model preference, change thinking level, or clear overrides'))
|
|
227
215
|
.setDMPermission(false)
|
|
228
216
|
.toJSON(),
|
|
217
|
+
new SlashCommandBuilder()
|
|
218
|
+
.setName('model-variant')
|
|
219
|
+
.setDescription(truncateCommandDescription('Change thinking level for current model. Tied to the model; lost when you switch models'))
|
|
220
|
+
.setDMPermission(false)
|
|
221
|
+
.toJSON(),
|
|
229
222
|
new SlashCommandBuilder()
|
|
230
223
|
.setName('login')
|
|
231
224
|
.setDescription(truncateCommandDescription('Authenticate with an AI provider (OAuth or API key). Use this instead of /connect'))
|
|
@@ -248,6 +241,18 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
248
241
|
})
|
|
249
242
|
.setDMPermission(false)
|
|
250
243
|
.toJSON(),
|
|
244
|
+
new SlashCommandBuilder()
|
|
245
|
+
.setName('clear-queue')
|
|
246
|
+
.setDescription(truncateCommandDescription('Clear all queued messages in this thread'))
|
|
247
|
+
.addIntegerOption((option) => {
|
|
248
|
+
option
|
|
249
|
+
.setName('position')
|
|
250
|
+
.setDescription(truncateCommandDescription('1-based queued message position to clear (default: all)'))
|
|
251
|
+
.setMinValue(1);
|
|
252
|
+
return option;
|
|
253
|
+
})
|
|
254
|
+
.setDMPermission(false)
|
|
255
|
+
.toJSON(),
|
|
251
256
|
new SlashCommandBuilder()
|
|
252
257
|
.setName('queue-command')
|
|
253
258
|
.setDescription(truncateCommandDescription('Queue a user command to run after the current response finishes'))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// E2e test for the default external_directory permission: every directory is
|
|
2
|
+
// allowed, so reading a file outside the project never shows a permission
|
|
3
|
+
// prompt. The old behaviour (allow-list + prompt) is now opt-in behind the
|
|
4
|
+
// --restrict-directories CLI flag.
|
|
5
|
+
import { describe, expect, test } from 'vitest';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
8
|
+
import { waitForBotMessageContaining, waitForFooterMessage, } from './test-utils.js';
|
|
9
|
+
const TEXT_CHANNEL_ID = '200000000000001021';
|
|
10
|
+
describe('external directory permissions', () => {
|
|
11
|
+
const ctx = setupQueueAdvancedSuite({
|
|
12
|
+
channelId: TEXT_CHANNEL_ID,
|
|
13
|
+
channelName: 'qa-external-directory-e2e',
|
|
14
|
+
dirName: 'qa-external-directory-e2e',
|
|
15
|
+
username: 'external-directory-tester',
|
|
16
|
+
});
|
|
17
|
+
test('reads outside the project without a permission prompt', async () => {
|
|
18
|
+
fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
|
|
19
|
+
fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'external directory file');
|
|
20
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
21
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER first',
|
|
22
|
+
});
|
|
23
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
24
|
+
timeout: 4_000,
|
|
25
|
+
predicate: (t) => {
|
|
26
|
+
return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const th = ctx.discord.thread(thread.id);
|
|
30
|
+
await waitForBotMessageContaining({
|
|
31
|
+
discord: ctx.discord,
|
|
32
|
+
threadId: thread.id,
|
|
33
|
+
userId: TEST_USER_ID,
|
|
34
|
+
text: 'external-directory-probe-done',
|
|
35
|
+
timeout: 8_000,
|
|
36
|
+
});
|
|
37
|
+
await waitForFooterMessage({
|
|
38
|
+
discord: ctx.discord,
|
|
39
|
+
threadId: thread.id,
|
|
40
|
+
timeout: 4_000,
|
|
41
|
+
afterMessageIncludes: 'external-directory-probe-done',
|
|
42
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
43
|
+
});
|
|
44
|
+
await th.user(TEST_USER_ID).sendMessage({
|
|
45
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER followup',
|
|
46
|
+
});
|
|
47
|
+
await waitForBotMessageContaining({
|
|
48
|
+
discord: ctx.discord,
|
|
49
|
+
threadId: thread.id,
|
|
50
|
+
userId: TEST_USER_ID,
|
|
51
|
+
text: 'external-directory-probe-done',
|
|
52
|
+
afterUserMessageIncludes: 'followup',
|
|
53
|
+
timeout: 8_000,
|
|
54
|
+
});
|
|
55
|
+
await waitForFooterMessage({
|
|
56
|
+
discord: ctx.discord,
|
|
57
|
+
threadId: thread.id,
|
|
58
|
+
timeout: 4_000,
|
|
59
|
+
afterMessageIncludes: 'external-directory-probe-done',
|
|
60
|
+
afterAuthorId: ctx.discord.botUserId,
|
|
61
|
+
});
|
|
62
|
+
const text = await th.text();
|
|
63
|
+
expect(text).toMatchInlineSnapshot(`
|
|
64
|
+
"--- from: user (external-directory-tester)
|
|
65
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER first
|
|
66
|
+
--- from: assistant (TestBot)
|
|
67
|
+
*using deterministic-provider/deterministic-v2*
|
|
68
|
+
⬥ reading external directory
|
|
69
|
+
┣ read *probe.txt*
|
|
70
|
+
⬥ external-directory-probe-done
|
|
71
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
72
|
+
--- from: user (external-directory-tester)
|
|
73
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER followup
|
|
74
|
+
--- from: assistant (TestBot)
|
|
75
|
+
⬥ reading external directory
|
|
76
|
+
┣ read *probe.txt*
|
|
77
|
+
⬥ external-directory-probe-done
|
|
78
|
+
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
79
|
+
`);
|
|
80
|
+
expect(text).not.toContain('Permission Required');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Regression test: a `deny` rule in the project's own opencode.json must still
|
|
2
|
+
// win over kimaki's allow-all default.
|
|
3
|
+
//
|
|
4
|
+
// This guards a real bug. opencode evaluates permissions with findLast() over
|
|
5
|
+
// merge(agent.permission, session.permission), so session rules are evaluated
|
|
6
|
+
// LAST and override user config. An earlier version of the allow-all default
|
|
7
|
+
// put `external_directory: '*' allow` into the session ruleset, which silently
|
|
8
|
+
// made every user `deny` rule a no-op. The allow now lives in the generated
|
|
9
|
+
// server config instead, where the project opencode.json deep-merges on top.
|
|
10
|
+
import { describe, expect, test } from 'vitest';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { EXTERNAL_DIRECTORY_PROBE_DIR, EXTERNAL_DIRECTORY_PROBE_FILE, setupQueueAdvancedSuite, TEST_USER_ID, } from './queue-advanced-e2e-setup.js';
|
|
13
|
+
import { waitForBotMessageContaining } from './test-utils.js';
|
|
14
|
+
const TEXT_CHANNEL_ID = '200000000000001023';
|
|
15
|
+
describe('external directory project deny', () => {
|
|
16
|
+
const ctx = setupQueueAdvancedSuite({
|
|
17
|
+
channelId: TEXT_CHANNEL_ID,
|
|
18
|
+
channelName: 'qa-external-directory-deny-e2e',
|
|
19
|
+
dirName: 'qa-external-directory-deny-e2e',
|
|
20
|
+
username: 'external-directory-deny-tester',
|
|
21
|
+
// Exactly what a user would write in their own opencode.json to protect a
|
|
22
|
+
// folder. Kimaki's generated config allows '*', this must still beat it.
|
|
23
|
+
projectPermission: {
|
|
24
|
+
external_directory: {
|
|
25
|
+
[EXTERNAL_DIRECTORY_PROBE_DIR]: 'deny',
|
|
26
|
+
[`${EXTERNAL_DIRECTORY_PROBE_DIR}/*`]: 'deny',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
test('project opencode.json deny beats the allow-all default', async () => {
|
|
31
|
+
fs.mkdirSync(EXTERNAL_DIRECTORY_PROBE_DIR, { recursive: true });
|
|
32
|
+
fs.writeFileSync(EXTERNAL_DIRECTORY_PROBE_FILE, 'protected file');
|
|
33
|
+
await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
|
|
34
|
+
content: 'EXTERNAL_DIRECTORY_PROBE_MARKER denied',
|
|
35
|
+
});
|
|
36
|
+
const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
|
|
37
|
+
timeout: 4_000,
|
|
38
|
+
predicate: (t) => {
|
|
39
|
+
return t.name?.includes('EXTERNAL_DIRECTORY_PROBE_MARKER') ?? false;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const th = ctx.discord.thread(thread.id);
|
|
43
|
+
// The deterministic matcher only emits this after the read comes back
|
|
44
|
+
// rejected by a permission rule, so seeing it proves the deny applied.
|
|
45
|
+
await waitForBotMessageContaining({
|
|
46
|
+
discord: ctx.discord,
|
|
47
|
+
threadId: thread.id,
|
|
48
|
+
userId: TEST_USER_ID,
|
|
49
|
+
text: 'external-directory-probe-denied',
|
|
50
|
+
timeout: 8_000,
|
|
51
|
+
});
|
|
52
|
+
const text = await th.text();
|
|
53
|
+
expect(text).toMatchInlineSnapshot(`
|
|
54
|
+
"--- from: user (external-directory-deny-tester)
|
|
55
|
+
EXTERNAL_DIRECTORY_PROBE_MARKER denied
|
|
56
|
+
--- from: assistant (TestBot)
|
|
57
|
+
*using deterministic-provider/deterministic-v2*
|
|
58
|
+
⬥ reading external directory
|
|
59
|
+
┣ read *probe.txt*
|
|
60
|
+
⬥ external-directory-probe-denied"
|
|
61
|
+
`);
|
|
62
|
+
// A deny is silent: it must not fall back to asking the user.
|
|
63
|
+
expect(text).not.toContain('Permission Required');
|
|
64
|
+
// And the read must not have succeeded, which is what the old
|
|
65
|
+
// session-level '*' allow rule caused.
|
|
66
|
+
expect(text).not.toContain('external-directory-probe-done');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/dist/format-tables.js
CHANGED
|
@@ -267,7 +267,7 @@ function flattenCalloutChildren({ segments, }) {
|
|
|
267
267
|
return [
|
|
268
268
|
{
|
|
269
269
|
type: ComponentType.TextDisplay,
|
|
270
|
-
content: segment.text.trim(),
|
|
270
|
+
content: clampTextDisplayContent(segment.text.trim()),
|
|
271
271
|
},
|
|
272
272
|
];
|
|
273
273
|
}
|
|
@@ -279,15 +279,29 @@ function flattenCalloutChildren({ segments, }) {
|
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
281
|
}
|
|
282
|
+
// Splits callout children into chunks that fit both the 40-component budget
|
|
283
|
+
// (reserving 1 for the Container wrapper itself, including nested children
|
|
284
|
+
// like ActionRow buttons via childComponentCost) and the 4000-char text
|
|
285
|
+
// budget. Mirrors chunkRowsByComponentLimit's approach for table rows.
|
|
282
286
|
function chunkCalloutChildrenByComponentLimit({ children, }) {
|
|
283
287
|
const chunks = [];
|
|
284
288
|
let currentChunk = [];
|
|
289
|
+
let currentCost = 1; // reserve for the Container wrapper
|
|
290
|
+
let currentText = 0;
|
|
285
291
|
for (const child of children) {
|
|
286
|
-
|
|
292
|
+
const childCost = childComponentCost(child);
|
|
293
|
+
const childText = componentTextSize(child);
|
|
294
|
+
if (currentChunk.length > 0 &&
|
|
295
|
+
(currentCost + childCost > MAX_COMPONENTS ||
|
|
296
|
+
currentText + childText > MAX_TEXT_SIZE)) {
|
|
287
297
|
chunks.push(currentChunk);
|
|
288
298
|
currentChunk = [];
|
|
299
|
+
currentCost = 1;
|
|
300
|
+
currentText = 0;
|
|
289
301
|
}
|
|
290
302
|
currentChunk.push(child);
|
|
303
|
+
currentCost += childCost;
|
|
304
|
+
currentText += childText;
|
|
291
305
|
}
|
|
292
306
|
if (currentChunk.length > 0) {
|
|
293
307
|
chunks.push(currentChunk);
|
|
@@ -423,16 +437,34 @@ function buildTextRow({ headers, cells, }) {
|
|
|
423
437
|
const value = cell ? getRenderedCellText({ cell }) : '';
|
|
424
438
|
return `**${key}** ${value}`;
|
|
425
439
|
});
|
|
440
|
+
const content = clampTextDisplayContent(lines.join('\n'));
|
|
426
441
|
return {
|
|
427
442
|
components: [
|
|
428
443
|
{
|
|
429
444
|
type: ComponentType.TextDisplay,
|
|
430
|
-
content
|
|
445
|
+
content,
|
|
431
446
|
},
|
|
432
447
|
],
|
|
433
448
|
componentCost: 1,
|
|
449
|
+
textSize: content.length,
|
|
434
450
|
};
|
|
435
451
|
}
|
|
452
|
+
// Discord's TextDisplay component enforces the same 4000-char content limit
|
|
453
|
+
// as the overall message text budget. A single row with one huge cell value
|
|
454
|
+
// (e.g. a long stack trace) could otherwise exceed it on its own, which no
|
|
455
|
+
// amount of row-level chunking can fix. `maxLength` lets callers reserve
|
|
456
|
+
// budget for sibling content (e.g. button labels) that shares the same
|
|
457
|
+
// message-wide text limit.
|
|
458
|
+
function clampTextDisplayContent(content, maxLength = MAX_TEXT_SIZE) {
|
|
459
|
+
const safeMax = Math.max(0, maxLength);
|
|
460
|
+
if (content.length <= safeMax) {
|
|
461
|
+
return content;
|
|
462
|
+
}
|
|
463
|
+
if (safeMax === 0) {
|
|
464
|
+
return '';
|
|
465
|
+
}
|
|
466
|
+
return content.slice(0, safeMax - 1) + '…';
|
|
467
|
+
}
|
|
436
468
|
function buildButtonRow({ headers, cells, }) {
|
|
437
469
|
const buttonCells = cells.filter((cell) => {
|
|
438
470
|
return cell.type === 'button';
|
|
@@ -463,32 +495,48 @@ function buildButtonRow({ headers, cells, }) {
|
|
|
463
495
|
type: ComponentType.ActionRow,
|
|
464
496
|
components: buttons,
|
|
465
497
|
};
|
|
498
|
+
const buttonLabelSize = buttonCells.reduce((sum, cell) => {
|
|
499
|
+
return sum + (cell.type === 'button' ? cell.label.length : 0);
|
|
500
|
+
}, 0);
|
|
501
|
+
// Reserve budget for button labels first since they can't be truncated,
|
|
502
|
+
// then clamp the TextDisplay content to whatever remains of MAX_TEXT_SIZE.
|
|
503
|
+
const content = clampTextDisplayContent(lines.join('\n'), MAX_TEXT_SIZE - buttonLabelSize);
|
|
466
504
|
return {
|
|
467
505
|
components: [
|
|
468
506
|
{
|
|
469
507
|
type: ComponentType.TextDisplay,
|
|
470
|
-
content
|
|
508
|
+
content,
|
|
471
509
|
},
|
|
472
510
|
actionRow,
|
|
473
511
|
],
|
|
474
512
|
componentCost: 2 + buttons.length,
|
|
513
|
+
textSize: content.length + buttonLabelSize,
|
|
475
514
|
};
|
|
476
515
|
}
|
|
516
|
+
// Splits rows into chunks that fit both the 40-component budget and the
|
|
517
|
+
// 4000-char displayable text budget. Wide/long tables (many long cell values)
|
|
518
|
+
// can stay under the component count while still exceeding Discord's text
|
|
519
|
+
// limit, which previously caused sendMessage to fail with a 400 error.
|
|
477
520
|
function chunkRowsByComponentLimit({ rows, }) {
|
|
478
521
|
const chunks = [];
|
|
479
522
|
let currentChunk = [];
|
|
480
523
|
let currentCost = 1;
|
|
524
|
+
let currentText = 0;
|
|
481
525
|
for (const row of rows) {
|
|
482
526
|
const separatorCost = currentChunk.length > 0 ? 1 : 0;
|
|
483
527
|
const nextCost = currentCost + separatorCost + row.componentCost;
|
|
484
|
-
|
|
528
|
+
const nextText = currentText + row.textSize;
|
|
529
|
+
if (currentChunk.length > 0 &&
|
|
530
|
+
(nextCost > MAX_COMPONENTS || nextText > MAX_TEXT_SIZE)) {
|
|
485
531
|
chunks.push(currentChunk);
|
|
486
532
|
currentChunk = [row];
|
|
487
533
|
currentCost = 1 + row.componentCost;
|
|
534
|
+
currentText = row.textSize;
|
|
488
535
|
continue;
|
|
489
536
|
}
|
|
490
537
|
currentChunk.push(row);
|
|
491
538
|
currentCost = nextCost;
|
|
539
|
+
currentText = nextText;
|
|
492
540
|
}
|
|
493
541
|
if (currentChunk.length > 0) {
|
|
494
542
|
chunks.push(currentChunk);
|
|
@@ -205,6 +205,85 @@ describe('buildTableComponents', () => {
|
|
|
205
205
|
]
|
|
206
206
|
`);
|
|
207
207
|
});
|
|
208
|
+
test('splits table into multiple containers when text size exceeds 4000 chars even though component count fits', () => {
|
|
209
|
+
// 10 rows well under the 40-component budget (1 container + 10 TDs + 9 seps = 20),
|
|
210
|
+
// but each row has a long cell value so total text exceeds MAX_TEXT_SIZE (4000).
|
|
211
|
+
const header = '| Name | Description |';
|
|
212
|
+
const sep = '| --- | --- |';
|
|
213
|
+
const longValue = 'x'.repeat(500);
|
|
214
|
+
const rows = Array.from({ length: 10 }, (_, i) => {
|
|
215
|
+
return `| row-${i} | ${longValue} |`;
|
|
216
|
+
}).join('\n');
|
|
217
|
+
const table = parseTable(`${header}\n${sep}\n${rows}`);
|
|
218
|
+
const result = buildTableComponents(table);
|
|
219
|
+
// Total text across all rows (~5000+ chars) must not fit in a single
|
|
220
|
+
// container; it should be split across multiple component segments.
|
|
221
|
+
expect(result.length).toBeGreaterThan(1);
|
|
222
|
+
for (const segment of result) {
|
|
223
|
+
if (segment.type !== 'components')
|
|
224
|
+
continue;
|
|
225
|
+
const container = segment.components[0];
|
|
226
|
+
if (!container || container.type !== ComponentType.Container)
|
|
227
|
+
continue;
|
|
228
|
+
const totalText = container.components.reduce((sum, child) => {
|
|
229
|
+
if ('content' in child && typeof child.content === 'string') {
|
|
230
|
+
return sum + child.content.length;
|
|
231
|
+
}
|
|
232
|
+
return sum;
|
|
233
|
+
}, 0);
|
|
234
|
+
expect(totalText).toBeLessThanOrEqual(4000);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
test('clamps a single row whose own content exceeds the 4000-char text limit', () => {
|
|
238
|
+
const header = '| Name | Description |';
|
|
239
|
+
const sep = '| --- | --- |';
|
|
240
|
+
const hugeValue = 'y'.repeat(5000);
|
|
241
|
+
const table = parseTable(`${header}\n${sep}\n| row | ${hugeValue} |`);
|
|
242
|
+
const result = buildTableComponents(table);
|
|
243
|
+
expect(result).toHaveLength(1);
|
|
244
|
+
const segment = result[0];
|
|
245
|
+
if (segment.type !== 'components')
|
|
246
|
+
throw new Error('expected components segment');
|
|
247
|
+
const container = segment.components[0];
|
|
248
|
+
if (container.type !== ComponentType.Container)
|
|
249
|
+
throw new Error('expected container');
|
|
250
|
+
const textDisplay = container.components[0];
|
|
251
|
+
if (textDisplay.type !== ComponentType.TextDisplay)
|
|
252
|
+
throw new Error('expected text display');
|
|
253
|
+
expect(textDisplay.content.length).toBeLessThanOrEqual(4000);
|
|
254
|
+
});
|
|
255
|
+
test('clamps a huge single row with a button so text + label stays under 4000 chars', () => {
|
|
256
|
+
// Regression: content used to be clamped to 4000 chars BEFORE adding the
|
|
257
|
+
// button label length, so a huge cell + button label could total >4000.
|
|
258
|
+
const header = '| Name | Description | Action |';
|
|
259
|
+
const sep = '| --- | --- | --- |';
|
|
260
|
+
const hugeValue = 'z'.repeat(5000);
|
|
261
|
+
const table = parseTable(`${header}\n${sep}\n| row | ${hugeValue} | <button id="delete-a" variant="secondary">Delete</button> |`);
|
|
262
|
+
const result = buildTableComponents(table, {
|
|
263
|
+
resolveButtonCustomId: ({ button }) => `action:${button.id}`,
|
|
264
|
+
});
|
|
265
|
+
expect(result).toHaveLength(1);
|
|
266
|
+
const segment = result[0];
|
|
267
|
+
if (segment.type !== 'components')
|
|
268
|
+
throw new Error('expected components segment');
|
|
269
|
+
const container = segment.components[0];
|
|
270
|
+
if (container.type !== ComponentType.Container)
|
|
271
|
+
throw new Error('expected container');
|
|
272
|
+
const totalText = container.components.reduce((sum, child) => {
|
|
273
|
+
if ('content' in child && typeof child.content === 'string') {
|
|
274
|
+
sum += child.content.length;
|
|
275
|
+
}
|
|
276
|
+
if ('components' in child && Array.isArray(child.components)) {
|
|
277
|
+
for (const nested of child.components) {
|
|
278
|
+
if ('label' in nested && typeof nested.label === 'string') {
|
|
279
|
+
sum += nested.label.length;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return sum;
|
|
284
|
+
}, 0);
|
|
285
|
+
expect(totalText).toBeLessThanOrEqual(4000);
|
|
286
|
+
});
|
|
208
287
|
test('renders wide rows with buttons without using sections', () => {
|
|
209
288
|
const table = parseTable(`| Thread | Name | Status | Created | Folder | Action |
|
|
210
289
|
| --- | --- | --- | --- | --- | --- |
|
|
@@ -475,6 +554,48 @@ Still open`);
|
|
|
475
554
|
]
|
|
476
555
|
`);
|
|
477
556
|
});
|
|
557
|
+
test('clamps callout plain text content that alone exceeds 4000 chars', () => {
|
|
558
|
+
const hugeBody = 'a'.repeat(5000);
|
|
559
|
+
const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">\n${hugeBody}\n</callout>`);
|
|
560
|
+
expect(result).toHaveLength(1);
|
|
561
|
+
const segment = result[0];
|
|
562
|
+
if (segment.type !== 'components')
|
|
563
|
+
throw new Error('expected components segment');
|
|
564
|
+
const container = segment.components[0];
|
|
565
|
+
if (container.type !== ComponentType.Container)
|
|
566
|
+
throw new Error('expected container');
|
|
567
|
+
const textDisplay = container.components[0];
|
|
568
|
+
if (textDisplay.type !== ComponentType.TextDisplay)
|
|
569
|
+
throw new Error('expected text display');
|
|
570
|
+
expect(textDisplay.content.length).toBeLessThanOrEqual(4000);
|
|
571
|
+
});
|
|
572
|
+
test('splits a callout with many button rows across chunks, counting nested button cost', () => {
|
|
573
|
+
// Regression: chunkCalloutChildrenByComponentLimit used to count
|
|
574
|
+
// `currentChunk.length` (1 per row) instead of the real component cost,
|
|
575
|
+
// so it never accounted for the ActionRow + Button nested inside each row
|
|
576
|
+
// (cost 3 per button row: TextDisplay + ActionRow + Button).
|
|
577
|
+
const header = '| Name | Action |';
|
|
578
|
+
const sep = '| --- | --- |';
|
|
579
|
+
const rows = Array.from({ length: 15 }, (_, i) => {
|
|
580
|
+
return `| wt-${i} | <button id="del-${i}" variant="secondary">Delete</button> |`;
|
|
581
|
+
}).join('\n');
|
|
582
|
+
const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">\n${header}\n${sep}\n${rows}\n</callout>`, {
|
|
583
|
+
resolveButtonCustomId: ({ button }) => `action:${button.id}`,
|
|
584
|
+
});
|
|
585
|
+
// 15 button rows: each row costs TextDisplay(1) + ActionRow(1) + Button(1) = 3,
|
|
586
|
+
// plus a separator between rows. Real cost far exceeds MAX_COMPONENTS (40)
|
|
587
|
+
// for one Container, so this must split into multiple segments.
|
|
588
|
+
expect(result.length).toBeGreaterThan(1);
|
|
589
|
+
for (const segment of result) {
|
|
590
|
+
if (segment.type !== 'components')
|
|
591
|
+
continue;
|
|
592
|
+
const container = segment.components[0];
|
|
593
|
+
if (!container || container.type !== ComponentType.Container)
|
|
594
|
+
continue;
|
|
595
|
+
const cost = countComponentCost(container);
|
|
596
|
+
expect(cost).toBeLessThanOrEqual(40);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
478
599
|
});
|
|
479
600
|
describe('truncateComponents', () => {
|
|
480
601
|
// Build a worktree-like table with button rows to emulate real /worktrees output.
|
|
@@ -14,7 +14,6 @@ import { handleRemoveProjectCommand, handleRemoveProjectAutocomplete, } from './
|
|
|
14
14
|
import { handleCreateNewProjectCommand } from './commands/create-new-project.js';
|
|
15
15
|
import { handlePermissionButton } from './commands/permissions.js';
|
|
16
16
|
import { handleAbortCommand } from './commands/abort.js';
|
|
17
|
-
import { handleAddDirCommand } from './commands/add-dir.js';
|
|
18
17
|
import { handleCompactCommand } from './commands/compact.js';
|
|
19
18
|
import { handleShareCommand } from './commands/share.js';
|
|
20
19
|
import { handleDiffCommand } from './commands/diff.js';
|
|
@@ -29,7 +28,7 @@ import { handleAskQuestionSelectMenu } from './commands/ask-question.js';
|
|
|
29
28
|
import { handleFileUploadButton, handleFileUploadModalSubmit, } from './commands/file-upload.js';
|
|
30
29
|
import { handleActionButton } from './commands/action-buttons.js';
|
|
31
30
|
import { handleHtmlActionButton } from './html-actions.js';
|
|
32
|
-
import { handleQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
|
|
31
|
+
import { handleQueueCommand, handleClearQueueCommand, handleQueueCommandCommand, handleQueueCommandAutocomplete, } from './commands/queue.js';
|
|
33
32
|
import { handleUndoCommand, handleRedoCommand } from './commands/undo-redo.js';
|
|
34
33
|
import { handleUserCommand } from './commands/user-command.js';
|
|
35
34
|
import { handleVerbosityCommand, handleVerbositySelectMenu, } from './commands/verbosity.js';
|
|
@@ -42,7 +41,7 @@ import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js';
|
|
|
42
41
|
import { handleScreenshareCommand } from './commands/screenshare.js';
|
|
43
42
|
import { handleVscodeCommand } from './commands/vscode.js';
|
|
44
43
|
import { handleModelVariantSelectMenu } from './commands/model.js';
|
|
45
|
-
import { handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
44
|
+
import { handleModelVariantCommand, handleVariantQuickSelectMenu, handleVariantScopeSelectMenu, } from './commands/model-variant.js';
|
|
46
45
|
import { hasKimakiAdminPermission, hasKimakiBotPermission } from './discord-utils.js';
|
|
47
46
|
import { createLogger, LogPrefix } from './logger.js';
|
|
48
47
|
import { notifyError } from './sentry.js';
|
|
@@ -200,9 +199,6 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
200
199
|
case 'abort':
|
|
201
200
|
await handleAbortCommand({ command: interaction, appId });
|
|
202
201
|
return;
|
|
203
|
-
case 'add-dir':
|
|
204
|
-
await handleAddDirCommand({ command: interaction, appId });
|
|
205
|
-
return;
|
|
206
202
|
case 'compact':
|
|
207
203
|
await handleCompactCommand({ command: interaction, appId });
|
|
208
204
|
return;
|
|
@@ -224,6 +220,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
224
220
|
case 'model':
|
|
225
221
|
await handleModelCommand({ interaction, appId });
|
|
226
222
|
return;
|
|
223
|
+
case 'model-variant':
|
|
224
|
+
await handleModelVariantCommand({ interaction, appId });
|
|
225
|
+
return;
|
|
227
226
|
case 'login':
|
|
228
227
|
if (!hasKimakiAdminPermission(interaction.member, interaction.guild)) {
|
|
229
228
|
await interaction.reply({
|
|
@@ -240,6 +239,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
|
|
|
240
239
|
case 'queue':
|
|
241
240
|
await handleQueueCommand({ command: interaction, appId });
|
|
242
241
|
return;
|
|
242
|
+
case 'clear-queue':
|
|
243
|
+
await handleClearQueueCommand({ command: interaction, appId });
|
|
244
|
+
return;
|
|
243
245
|
case 'queue-command':
|
|
244
246
|
await handleQueueCommandCommand({ command: interaction, appId });
|
|
245
247
|
return;
|