@vellumai/assistant 0.5.2 → 0.5.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/ARCHITECTURE.md +109 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +699 -0
- package/src/__tests__/memory-reducer.test.ts +698 -0
- package/src/__tests__/memory-regressions.test.ts +6 -4
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/loader.ts +1 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-runtime-assembly.ts +2 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +9 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +161 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +245 -101
- package/src/memory/db-init.ts +12 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +6 -0
- package/src/memory/jobs-worker.ts +12 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +99 -0
- package/src/memory/reducer.ts +453 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/routes/conversation-management-routes.ts +6 -0
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +2 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +21 -0
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
mock,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
16
|
+
|
|
17
|
+
const testDir = mkdtempSync(join(tmpdir(), "memory-reducer-checkpoints-"));
|
|
18
|
+
const dbPath = join(testDir, "test.db");
|
|
19
|
+
const originalBunTest = process.env.BUN_TEST;
|
|
20
|
+
|
|
21
|
+
mock.module("../util/platform.js", () => ({
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === "darwin",
|
|
24
|
+
isLinux: () => process.platform === "linux",
|
|
25
|
+
isWindows: () => process.platform === "win32",
|
|
26
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
27
|
+
getDbPath: () => dbPath,
|
|
28
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
29
|
+
ensureDataDir: () => {},
|
|
30
|
+
getConversationsDir: () => join(testDir, "conversations"),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module("../util/logger.js", () => ({
|
|
34
|
+
getLogger: () =>
|
|
35
|
+
new Proxy({} as Record<string, unknown>, {
|
|
36
|
+
get: () => () => {},
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { initializeDb, resetDb } from "../memory/db.js";
|
|
41
|
+
import { getSqliteFrom } from "../memory/db-connection.js";
|
|
42
|
+
import { migrateMemoryReducerCheckpoints } from "../memory/migrations/187-memory-reducer-checkpoints.js";
|
|
43
|
+
import * as schema from "../memory/schema.js";
|
|
44
|
+
|
|
45
|
+
function createTestDb() {
|
|
46
|
+
const sqlite = new Database(":memory:");
|
|
47
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
48
|
+
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
49
|
+
return drizzle(sqlite, { schema });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getColumnInfo(
|
|
53
|
+
raw: Database,
|
|
54
|
+
): Array<{ name: string; notnull: number }> {
|
|
55
|
+
return raw.query(`PRAGMA table_info(conversations)`).all() as Array<{
|
|
56
|
+
name: string;
|
|
57
|
+
notnull: number;
|
|
58
|
+
}>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function bootstrapPreCheckpointConversations(raw: Database): void {
|
|
62
|
+
raw.exec(/*sql*/ `
|
|
63
|
+
CREATE TABLE conversations (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
title TEXT,
|
|
66
|
+
created_at INTEGER NOT NULL,
|
|
67
|
+
updated_at INTEGER NOT NULL,
|
|
68
|
+
total_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
total_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
total_estimated_cost REAL NOT NULL DEFAULT 0,
|
|
71
|
+
context_summary TEXT,
|
|
72
|
+
context_compacted_message_count INTEGER NOT NULL DEFAULT 0,
|
|
73
|
+
context_compacted_at INTEGER,
|
|
74
|
+
conversation_type TEXT NOT NULL DEFAULT 'standard',
|
|
75
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
76
|
+
memory_scope_id TEXT NOT NULL DEFAULT 'default',
|
|
77
|
+
origin_channel TEXT,
|
|
78
|
+
origin_interface TEXT,
|
|
79
|
+
fork_parent_conversation_id TEXT,
|
|
80
|
+
fork_parent_message_id TEXT,
|
|
81
|
+
is_auto_title INTEGER NOT NULL DEFAULT 1,
|
|
82
|
+
schedule_job_id TEXT
|
|
83
|
+
)
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function removeTestDbFiles(): void {
|
|
88
|
+
rmSync(dbPath, { force: true });
|
|
89
|
+
rmSync(`${dbPath}-shm`, { force: true });
|
|
90
|
+
rmSync(`${dbPath}-wal`, { force: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("memory reducer checkpoint columns migration", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
process.env.BUN_TEST = "0";
|
|
96
|
+
resetDb();
|
|
97
|
+
removeTestDbFiles();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
resetDb();
|
|
102
|
+
removeTestDbFiles();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterAll(() => {
|
|
106
|
+
if (originalBunTest === undefined) {
|
|
107
|
+
delete process.env.BUN_TEST;
|
|
108
|
+
} else {
|
|
109
|
+
process.env.BUN_TEST = originalBunTest;
|
|
110
|
+
}
|
|
111
|
+
resetDb();
|
|
112
|
+
removeTestDbFiles();
|
|
113
|
+
try {
|
|
114
|
+
rmSync(testDir, { recursive: true });
|
|
115
|
+
} catch {
|
|
116
|
+
/* best effort */
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("fresh DB initialization includes nullable reducer checkpoint columns", () => {
|
|
121
|
+
initializeDb();
|
|
122
|
+
|
|
123
|
+
const raw = new Database(dbPath);
|
|
124
|
+
const columns = getColumnInfo(raw);
|
|
125
|
+
|
|
126
|
+
const checkpointColumns = columns.filter(
|
|
127
|
+
(c) =>
|
|
128
|
+
c.name === "memory_reduced_through_message_id" ||
|
|
129
|
+
c.name === "memory_dirty_tail_since_message_id" ||
|
|
130
|
+
c.name === "memory_last_reduced_at",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(checkpointColumns).toHaveLength(3);
|
|
134
|
+
expect(checkpointColumns.every((c) => c.notnull === 0)).toBe(true);
|
|
135
|
+
|
|
136
|
+
raw.close();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("migration upgrades the pre-checkpoint schema without disturbing existing rows", () => {
|
|
140
|
+
const db = createTestDb();
|
|
141
|
+
const raw = getSqliteFrom(db);
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
|
|
144
|
+
bootstrapPreCheckpointConversations(raw);
|
|
145
|
+
raw.exec(/*sql*/ `
|
|
146
|
+
INSERT INTO conversations (
|
|
147
|
+
id,
|
|
148
|
+
title,
|
|
149
|
+
created_at,
|
|
150
|
+
updated_at,
|
|
151
|
+
conversation_type,
|
|
152
|
+
source,
|
|
153
|
+
memory_scope_id,
|
|
154
|
+
is_auto_title
|
|
155
|
+
) VALUES (
|
|
156
|
+
'conv-upgrade',
|
|
157
|
+
'Existing conversation',
|
|
158
|
+
${now},
|
|
159
|
+
${now},
|
|
160
|
+
'standard',
|
|
161
|
+
'user',
|
|
162
|
+
'default',
|
|
163
|
+
1
|
|
164
|
+
)
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
migrateMemoryReducerCheckpoints(db);
|
|
168
|
+
|
|
169
|
+
const columnNames = getColumnInfo(raw).map((c) => c.name);
|
|
170
|
+
expect(columnNames).toContain("memory_reduced_through_message_id");
|
|
171
|
+
expect(columnNames).toContain("memory_dirty_tail_since_message_id");
|
|
172
|
+
expect(columnNames).toContain("memory_last_reduced_at");
|
|
173
|
+
|
|
174
|
+
const row = raw
|
|
175
|
+
.query(
|
|
176
|
+
`SELECT id, title, memory_reduced_through_message_id, memory_dirty_tail_since_message_id, memory_last_reduced_at
|
|
177
|
+
FROM conversations WHERE id = 'conv-upgrade'`,
|
|
178
|
+
)
|
|
179
|
+
.get() as {
|
|
180
|
+
id: string;
|
|
181
|
+
title: string | null;
|
|
182
|
+
memory_reduced_through_message_id: string | null;
|
|
183
|
+
memory_dirty_tail_since_message_id: string | null;
|
|
184
|
+
memory_last_reduced_at: number | null;
|
|
185
|
+
} | null;
|
|
186
|
+
|
|
187
|
+
expect(row).toEqual({
|
|
188
|
+
id: "conv-upgrade",
|
|
189
|
+
title: "Existing conversation",
|
|
190
|
+
memory_reduced_through_message_id: null,
|
|
191
|
+
memory_dirty_tail_since_message_id: null,
|
|
192
|
+
memory_last_reduced_at: null,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
raw.close();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("re-running the migration preserves populated checkpoint values", () => {
|
|
199
|
+
const db = createTestDb();
|
|
200
|
+
const raw = getSqliteFrom(db);
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
|
|
203
|
+
bootstrapPreCheckpointConversations(raw);
|
|
204
|
+
raw.exec(/*sql*/ `
|
|
205
|
+
INSERT INTO conversations (
|
|
206
|
+
id,
|
|
207
|
+
title,
|
|
208
|
+
created_at,
|
|
209
|
+
updated_at,
|
|
210
|
+
conversation_type,
|
|
211
|
+
source,
|
|
212
|
+
memory_scope_id,
|
|
213
|
+
is_auto_title
|
|
214
|
+
) VALUES (
|
|
215
|
+
'conv-rerun',
|
|
216
|
+
'Reduced conversation',
|
|
217
|
+
${now},
|
|
218
|
+
${now},
|
|
219
|
+
'standard',
|
|
220
|
+
'user',
|
|
221
|
+
'default',
|
|
222
|
+
1
|
|
223
|
+
)
|
|
224
|
+
`);
|
|
225
|
+
|
|
226
|
+
migrateMemoryReducerCheckpoints(db);
|
|
227
|
+
raw.exec(/*sql*/ `
|
|
228
|
+
UPDATE conversations
|
|
229
|
+
SET memory_reduced_through_message_id = 'msg-100',
|
|
230
|
+
memory_dirty_tail_since_message_id = 'msg-101',
|
|
231
|
+
memory_last_reduced_at = ${now}
|
|
232
|
+
WHERE id = 'conv-rerun'
|
|
233
|
+
`);
|
|
234
|
+
|
|
235
|
+
expect(() => migrateMemoryReducerCheckpoints(db)).not.toThrow();
|
|
236
|
+
|
|
237
|
+
const row = raw
|
|
238
|
+
.query(
|
|
239
|
+
`SELECT memory_reduced_through_message_id, memory_dirty_tail_since_message_id, memory_last_reduced_at
|
|
240
|
+
FROM conversations WHERE id = 'conv-rerun'`,
|
|
241
|
+
)
|
|
242
|
+
.get() as {
|
|
243
|
+
memory_reduced_through_message_id: string | null;
|
|
244
|
+
memory_dirty_tail_since_message_id: string | null;
|
|
245
|
+
memory_last_reduced_at: number | null;
|
|
246
|
+
} | null;
|
|
247
|
+
|
|
248
|
+
expect(row).toEqual({
|
|
249
|
+
memory_reduced_through_message_id: "msg-100",
|
|
250
|
+
memory_dirty_tail_since_message_id: "msg-101",
|
|
251
|
+
memory_last_reduced_at: now,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
raw.close();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("getConversation exposes the new checkpoint fields as null for new rows", async () => {
|
|
258
|
+
initializeDb();
|
|
259
|
+
|
|
260
|
+
// Dynamic import to avoid circular module init issues — conversation-crud
|
|
261
|
+
// depends on getDb being initialized which happens in initializeDb above.
|
|
262
|
+
const { createConversation, getConversation } =
|
|
263
|
+
await import("../memory/conversation-crud.js");
|
|
264
|
+
|
|
265
|
+
const created = createConversation("Test conversation");
|
|
266
|
+
const loaded = getConversation(created.id);
|
|
267
|
+
|
|
268
|
+
expect(loaded).not.toBeNull();
|
|
269
|
+
expect(loaded!.memoryReducedThroughMessageId).toBeNull();
|
|
270
|
+
expect(loaded!.memoryDirtyTailSinceMessageId).toBeNull();
|
|
271
|
+
expect(loaded!.memoryLastReducedAt).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { InlineCommandResult } from "../skills/inline-command-runner.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Mocks — must be declared before the module under test is imported
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const mockConfig = {
|
|
10
|
+
provider: "anthropic",
|
|
11
|
+
model: "test",
|
|
12
|
+
maxTokens: 4096,
|
|
13
|
+
dataDir: "/tmp",
|
|
14
|
+
sandbox: { enabled: true },
|
|
15
|
+
timeouts: {
|
|
16
|
+
shellDefaultTimeoutSec: 120,
|
|
17
|
+
shellMaxTimeoutSec: 600,
|
|
18
|
+
permissionTimeoutSec: 300,
|
|
19
|
+
},
|
|
20
|
+
rateLimit: { maxRequestsPerMinute: 0 },
|
|
21
|
+
secretDetection: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
action: "warn" as const,
|
|
24
|
+
entropyThreshold: 4.0,
|
|
25
|
+
},
|
|
26
|
+
auditLog: { retentionDays: 0 },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
mock.module("../config/loader.js", () => ({
|
|
30
|
+
getConfig: () => mockConfig,
|
|
31
|
+
loadConfig: () => mockConfig,
|
|
32
|
+
invalidateConfigCache: () => {},
|
|
33
|
+
saveConfig: () => {},
|
|
34
|
+
loadRawConfig: () => ({}),
|
|
35
|
+
saveRawConfig: () => {},
|
|
36
|
+
getNestedValue: () => undefined,
|
|
37
|
+
setNestedValue: () => {},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
mock.module("../util/logger.js", () => ({
|
|
41
|
+
getLogger: () =>
|
|
42
|
+
new Proxy({} as Record<string, unknown>, {
|
|
43
|
+
get: () => () => {},
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// Track wrapCommand calls to verify sandbox-only execution
|
|
48
|
+
let lastWrapCall: {
|
|
49
|
+
command: string;
|
|
50
|
+
workingDir: string;
|
|
51
|
+
config: { enabled: boolean };
|
|
52
|
+
options?: { networkMode?: string };
|
|
53
|
+
} | null = null;
|
|
54
|
+
|
|
55
|
+
mock.module("../tools/terminal/sandbox.js", () => ({
|
|
56
|
+
wrapCommand: (
|
|
57
|
+
command: string,
|
|
58
|
+
workingDir: string,
|
|
59
|
+
config: { enabled: boolean },
|
|
60
|
+
options?: { networkMode?: string },
|
|
61
|
+
) => {
|
|
62
|
+
lastWrapCall = { command, workingDir, config, options };
|
|
63
|
+
return {
|
|
64
|
+
command: "bash",
|
|
65
|
+
args: ["-c", "--", command],
|
|
66
|
+
sandboxed: false,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
mock.module("../tools/terminal/safe-env.js", () => ({
|
|
72
|
+
buildSanitizedEnv: () => ({
|
|
73
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
74
|
+
HOME: process.env.HOME ?? "/tmp",
|
|
75
|
+
}),
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
import { runInlineCommand } from "../skills/inline-command-runner.js";
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Helpers
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
const CWD = process.cwd();
|
|
85
|
+
|
|
86
|
+
function expectOk(result: InlineCommandResult): void {
|
|
87
|
+
expect(result.ok).toBe(true);
|
|
88
|
+
expect(result.failureReason).toBeUndefined();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function expectFailure(
|
|
92
|
+
result: InlineCommandResult,
|
|
93
|
+
reason: InlineCommandResult["failureReason"],
|
|
94
|
+
): void {
|
|
95
|
+
expect(result.ok).toBe(false);
|
|
96
|
+
expect(result.failureReason).toBe(reason);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Tests
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("runInlineCommand", () => {
|
|
104
|
+
// ── Sandbox enforcement ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe("sandbox enforcement", () => {
|
|
107
|
+
test("always passes sandbox config with enabled=true", async () => {
|
|
108
|
+
lastWrapCall = null;
|
|
109
|
+
await runInlineCommand("echo sandbox-check", CWD);
|
|
110
|
+
|
|
111
|
+
expect(lastWrapCall).not.toBeNull();
|
|
112
|
+
expect(lastWrapCall!.config.enabled).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("always passes networkMode=off", async () => {
|
|
116
|
+
lastWrapCall = null;
|
|
117
|
+
await runInlineCommand("echo network-check", CWD);
|
|
118
|
+
|
|
119
|
+
expect(lastWrapCall).not.toBeNull();
|
|
120
|
+
expect(lastWrapCall!.options?.networkMode).toBe("off");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("uses the provided workingDir as cwd", async () => {
|
|
124
|
+
lastWrapCall = null;
|
|
125
|
+
const customDir = "/tmp/my-project";
|
|
126
|
+
await runInlineCommand("echo cwd-check", customDir);
|
|
127
|
+
|
|
128
|
+
expect(lastWrapCall).not.toBeNull();
|
|
129
|
+
expect(lastWrapCall!.workingDir).toBe(customDir);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("passes the literal command string to the sandbox", async () => {
|
|
133
|
+
lastWrapCall = null;
|
|
134
|
+
await runInlineCommand("git log --oneline -n 5", CWD);
|
|
135
|
+
|
|
136
|
+
expect(lastWrapCall).not.toBeNull();
|
|
137
|
+
expect(lastWrapCall!.command).toBe("git log --oneline -n 5");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── Successful execution ─────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe("successful execution", () => {
|
|
144
|
+
test("captures stdout from a simple echo", async () => {
|
|
145
|
+
const result = await runInlineCommand("echo hello-world", CWD);
|
|
146
|
+
|
|
147
|
+
expectOk(result);
|
|
148
|
+
expect(result.output).toBe("hello-world");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("captures multi-line stdout", async () => {
|
|
152
|
+
const result = await runInlineCommand(
|
|
153
|
+
"printf 'line1\\nline2\\nline3'",
|
|
154
|
+
CWD,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expectOk(result);
|
|
158
|
+
expect(result.output).toContain("line1");
|
|
159
|
+
expect(result.output).toContain("line2");
|
|
160
|
+
expect(result.output).toContain("line3");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("returns empty string for command with no output", async () => {
|
|
164
|
+
const result = await runInlineCommand("true", CWD);
|
|
165
|
+
|
|
166
|
+
expectOk(result);
|
|
167
|
+
expect(result.output).toBe("");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── ANSI stripping ──────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("ANSI stripping", () => {
|
|
174
|
+
test("strips SGR color codes from output", async () => {
|
|
175
|
+
const result = await runInlineCommand(
|
|
176
|
+
"printf '\\033[31mred\\033[0m normal'",
|
|
177
|
+
CWD,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expectOk(result);
|
|
181
|
+
expect(result.output).toBe("red normal");
|
|
182
|
+
expect(result.output).not.toContain("\x1b");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("strips cursor movement sequences", async () => {
|
|
186
|
+
const result = await runInlineCommand("printf '\\033[2Ahello'", CWD);
|
|
187
|
+
|
|
188
|
+
expectOk(result);
|
|
189
|
+
expect(result.output).toBe("hello");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Binary output rejection ──────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe("binary output rejection", () => {
|
|
196
|
+
test("rejects binary-ish output", async () => {
|
|
197
|
+
// Generate output with >10% control characters
|
|
198
|
+
const result = await runInlineCommand(
|
|
199
|
+
"printf '\\x00\\x01\\x02\\x03\\x04\\x05\\x06\\x07abc'",
|
|
200
|
+
CWD,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expectFailure(result, "binary_output");
|
|
204
|
+
expect(result.output).toBe("Inline command produced binary output.");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── Output clamping ──────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("output clamping", () => {
|
|
211
|
+
test("truncates output exceeding the cap", async () => {
|
|
212
|
+
// Generate output larger than a small cap
|
|
213
|
+
const result = await runInlineCommand("printf '%0.s-' {1..200}", CWD, {
|
|
214
|
+
maxOutputChars: 50,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expectOk(result);
|
|
218
|
+
expect(result.output.length).toBeLessThanOrEqual(
|
|
219
|
+
50 + "\n[output truncated]".length,
|
|
220
|
+
);
|
|
221
|
+
expect(result.output).toContain("[output truncated]");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("does not truncate output under the cap", async () => {
|
|
225
|
+
const result = await runInlineCommand("echo short", CWD, {
|
|
226
|
+
maxOutputChars: 1000,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expectOk(result);
|
|
230
|
+
expect(result.output).toBe("short");
|
|
231
|
+
expect(result.output).not.toContain("[output truncated]");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Timeout handling ─────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
describe("timeout handling", () => {
|
|
238
|
+
test("produces deterministic timeout result", async () => {
|
|
239
|
+
const result = await runInlineCommand("sleep 60", CWD, {
|
|
240
|
+
timeoutMs: 200,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expectFailure(result, "timeout");
|
|
244
|
+
expect(result.output).toBe("Inline command timed out after 200ms.");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── Non-zero exit ────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe("non-zero exit", () => {
|
|
251
|
+
test("produces deterministic failure for exit code 1", async () => {
|
|
252
|
+
const result = await runInlineCommand("exit 1", CWD);
|
|
253
|
+
|
|
254
|
+
expectFailure(result, "non_zero_exit");
|
|
255
|
+
expect(result.output).toBe("Inline command failed (exit code 1).");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("produces deterministic failure for exit code 127", async () => {
|
|
259
|
+
const result = await runInlineCommand(
|
|
260
|
+
"nonexistent_command_that_does_not_exist_xyz",
|
|
261
|
+
CWD,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expectFailure(result, "non_zero_exit");
|
|
265
|
+
expect(result.output).toMatch(
|
|
266
|
+
/Inline command failed \(exit code \d+\)\./,
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("does not expose stderr in the error result", async () => {
|
|
271
|
+
const result = await runInlineCommand("echo err-msg >&2 && exit 1", CWD);
|
|
272
|
+
|
|
273
|
+
expectFailure(result, "non_zero_exit");
|
|
274
|
+
expect(result.output).not.toContain("err-msg");
|
|
275
|
+
expect(result.output).toBe("Inline command failed (exit code 1).");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── Spawn failures ───────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
describe("spawn failures", () => {
|
|
282
|
+
test("returns spawn_failure when cwd does not exist", async () => {
|
|
283
|
+
// When the working directory doesn't exist, the child process fails to
|
|
284
|
+
// start (ENOENT from posix_spawn). The runner should catch this and
|
|
285
|
+
// return a deterministic spawn_failure result.
|
|
286
|
+
const result = await runInlineCommand(
|
|
287
|
+
"echo hello",
|
|
288
|
+
"/nonexistent/path/that/does/not/exist",
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
expectFailure(result, "spawn_failure");
|
|
292
|
+
expect(result.output).toBe("Inline command could not be started.");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── stderr suppression ─────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
describe("stderr suppression", () => {
|
|
299
|
+
test("does not include stderr in successful output", async () => {
|
|
300
|
+
const result = await runInlineCommand(
|
|
301
|
+
"echo stdout-only && echo stderr-msg >&2",
|
|
302
|
+
CWD,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// Command may succeed (exit 0) — stderr should not leak into output
|
|
306
|
+
expectOk(result);
|
|
307
|
+
expect(result.output).toBe("stdout-only");
|
|
308
|
+
expect(result.output).not.toContain("stderr-msg");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|