@vellumai/assistant 0.5.5 → 0.5.6

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.
Files changed (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. package/src/memory/schema/memory-brief.ts +0 -55
@@ -11,10 +11,7 @@ import type { MemoryJob } from "../jobs-store.js";
11
11
  import { extractMediaBlocks } from "../message-content.js";
12
12
  import {
13
13
  mediaAssets,
14
- memoryChunks,
15
- memoryEpisodes,
16
14
  memoryItems,
17
- memoryObservations,
18
15
  memorySegments,
19
16
  memorySummaries,
20
17
  messages,
@@ -93,26 +90,6 @@ export async function embedSummaryJob(
93
90
  );
94
91
  }
95
92
 
96
- export async function embedChunkJob(
97
- job: MemoryJob,
98
- config: AssistantConfig,
99
- ): Promise<void> {
100
- const chunkId = asString(job.payload.chunkId);
101
- if (!chunkId) return;
102
- const db = getDb();
103
- const chunk = db
104
- .select()
105
- .from(memoryChunks)
106
- .where(eq(memoryChunks.id, chunkId))
107
- .get();
108
- if (!chunk) return;
109
- await embedAndUpsert(config, "chunk", chunk.id, chunk.content, {
110
- observation_id: chunk.observationId,
111
- created_at: chunk.createdAt,
112
- memory_scope_id: chunk.scopeId,
113
- });
114
- }
115
-
116
93
  export async function embedMediaJob(
117
94
  job: MemoryJob,
118
95
  config: AssistantConfig,
@@ -146,40 +123,6 @@ export async function embedMediaJob(
146
123
  });
147
124
  }
148
125
 
149
- export async function embedObservationJob(
150
- job: MemoryJob,
151
- config: AssistantConfig,
152
- ): Promise<void> {
153
- const observationId = asString(job.payload.observationId);
154
- const chunkId = asString(job.payload.chunkId);
155
- if (!observationId || !chunkId) return;
156
-
157
- const db = getDb();
158
- const observation = db
159
- .select()
160
- .from(memoryObservations)
161
- .where(eq(memoryObservations.id, observationId))
162
- .get();
163
- if (!observation) return;
164
-
165
- const chunk = db
166
- .select()
167
- .from(memoryChunks)
168
- .where(eq(memoryChunks.id, chunkId))
169
- .get();
170
- if (!chunk) return;
171
-
172
- await embedAndUpsert(config, "observation", chunk.id, chunk.content, {
173
- observation_id: observationId,
174
- conversation_id: observation.conversationId,
175
- role: observation.role,
176
- modality: observation.modality,
177
- source: observation.source,
178
- created_at: observation.createdAt,
179
- memory_scope_id: observation.scopeId,
180
- });
181
- }
182
-
183
126
  export async function embedAttachmentJob(
184
127
  job: MemoryJob,
185
128
  config: AssistantConfig,
@@ -216,25 +159,3 @@ export async function embedAttachmentJob(
216
159
  memory_scope_id: memoryScopeId,
217
160
  });
218
161
  }
219
-
220
- export async function embedEpisodeJob(
221
- job: MemoryJob,
222
- config: AssistantConfig,
223
- ): Promise<void> {
224
- const episodeId = asString(job.payload.episodeId);
225
- if (!episodeId) return;
226
- const db = getDb();
227
- const episode = db
228
- .select()
229
- .from(memoryEpisodes)
230
- .where(eq(memoryEpisodes.id, episodeId))
231
- .get();
232
- if (!episode) return;
233
- const text = `[episode] ${episode.title}: ${episode.summary}`;
234
- await embedAndUpsert(config, "episode", episode.id, text, {
235
- conversation_id: episode.conversationId,
236
- created_at: episode.startAt,
237
- last_seen_at: episode.endAt,
238
- memory_scope_id: episode.scopeId,
239
- });
240
- }
@@ -142,7 +142,7 @@ export function truncate(text: string, max: number): string {
142
142
 
143
143
  export async function embedAndUpsert(
144
144
  config: AssistantConfig,
145
- targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
145
+ targetType: "segment" | "item" | "summary" | "media",
146
146
  targetId: string,
147
147
  input: EmbeddingInput,
148
148
  extraPayload?: Record<string, unknown>,
@@ -12,9 +12,6 @@ export type MemoryJobType =
12
12
  | "embed_segment"
13
13
  | "embed_item"
14
14
  | "embed_summary"
15
- | "embed_chunk"
16
- | "embed_episode"
17
- | "embed_observation"
18
15
  | "extract_items"
19
16
  | "extract_entities"
20
17
  | "cleanup_stale_superseded_items"
@@ -30,8 +27,6 @@ export type MemoryJobType =
30
27
  | "embed_media"
31
28
  | "embed_attachment"
32
29
  | "generate_conversation_starters"
33
- | "reduce_conversation_memory"
34
- | "backfill_simplified_memory"
35
30
  | "generate_capability_cards" // legacy compat — silently dropped by worker (capability cards removed)
36
31
  | "generate_thread_starters"; // legacy compat — silently dropped by worker (renamed to generate_conversation_starters)
37
32
 
@@ -39,9 +34,6 @@ const EMBED_JOB_TYPES: MemoryJobType[] = [
39
34
  "embed_segment",
40
35
  "embed_item",
41
36
  "embed_summary",
42
- "embed_chunk",
43
- "embed_episode",
44
- "embed_observation",
45
37
  "embed_media",
46
38
  "embed_attachment",
47
39
  ];
@@ -3,7 +3,6 @@ import type { AssistantConfig } from "../config/types.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { rawRun } from "./db.js";
5
5
  import { backfillJob } from "./job-handlers/backfill.js";
6
- import { backfillSimplifiedMemoryJob } from "./job-handlers/backfill-simplified-memory.js";
7
6
  import {
8
7
  cleanupStaleSupersededItemsJob,
9
8
  pruneOldConversationsJob,
@@ -12,11 +11,8 @@ import { generateConversationStartersJob } from "./job-handlers/conversation-sta
12
11
  // ── Per-job-type handlers ──────────────────────────────────────────
13
12
  import {
14
13
  embedAttachmentJob,
15
- embedChunkJob,
16
- embedEpisodeJob,
17
14
  embedItemJob,
18
15
  embedMediaJob,
19
- embedObservationJob,
20
16
  embedSegmentJob,
21
17
  embedSummaryJob,
22
18
  } from "./job-handlers/embedding.js";
@@ -26,7 +22,6 @@ import {
26
22
  rebuildIndexJob,
27
23
  } from "./job-handlers/index-maintenance.js";
28
24
  import { mediaProcessingJob } from "./job-handlers/media-processing.js";
29
- import { reduceConversationMemoryJob } from "./job-handlers/reduce-conversation-memory.js";
30
25
  import { buildConversationSummaryJob } from "./job-handlers/summarization.js";
31
26
  import {
32
27
  BackendUnavailableError,
@@ -272,15 +267,6 @@ async function processJob(
272
267
  case "embed_summary":
273
268
  await embedSummaryJob(job, config);
274
269
  return;
275
- case "embed_chunk":
276
- await embedChunkJob(job, config);
277
- return;
278
- case "embed_episode":
279
- await embedEpisodeJob(job, config);
280
- return;
281
- case "embed_observation":
282
- await embedObservationJob(job, config);
283
- return;
284
270
  case "extract_items":
285
271
  await extractItemsJob(job);
286
272
  return;
@@ -321,12 +307,6 @@ async function processJob(
321
307
  case "embed_attachment":
322
308
  await embedAttachmentJob(job, config);
323
309
  return;
324
- case "reduce_conversation_memory":
325
- await reduceConversationMemoryJob(job);
326
- return;
327
- case "backfill_simplified_memory":
328
- await backfillSimplifiedMemoryJob(job);
329
- return;
330
310
  case "generate_conversation_starters":
331
311
  await generateConversationStartersJob(job);
332
312
  return;
@@ -0,0 +1,42 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { getSqliteFrom } from "../db-connection.js";
3
+
4
+ /**
5
+ * Drop simplified-memory tables and reducer checkpoint columns added by
6
+ * the simplified-memory-v1 plan, reverting to the legacy item/tier/XML
7
+ * memory system.
8
+ */
9
+ export function migrateDropSimplifiedMemory(database: DrizzleDb): void {
10
+ const raw = getSqliteFrom(database);
11
+
12
+ // Drop simplified-memory tables (idempotent — IF EXISTS).
13
+ raw.exec(`DROP TABLE IF EXISTS time_contexts`);
14
+ raw.exec(`DROP TABLE IF EXISTS open_loops`);
15
+ raw.exec(`DROP TABLE IF EXISTS memory_observations`);
16
+ raw.exec(`DROP TABLE IF EXISTS memory_chunks`);
17
+ raw.exec(`DROP TABLE IF EXISTS memory_episodes`);
18
+
19
+ // Remove reducer checkpoint columns from conversations.
20
+ // SQLite doesn't support DROP COLUMN before 3.35.0, but Bun's built-in
21
+ // SQLite is >= 3.38, so this is safe.
22
+ for (const col of [
23
+ "memory_reduced_through_message_id",
24
+ "memory_dirty_tail_since_message_id",
25
+ "memory_last_reduced_at",
26
+ ]) {
27
+ try {
28
+ raw.exec(`ALTER TABLE conversations DROP COLUMN ${col}`);
29
+ } catch {
30
+ // Column doesn't exist — already cleaned up.
31
+ }
32
+ }
33
+
34
+ // Remove embedding rows for archive target types that no longer exist.
35
+ try {
36
+ raw.exec(
37
+ `DELETE FROM memory_embeddings WHERE target_type IN ('observation', 'chunk', 'episode')`,
38
+ );
39
+ } catch {
40
+ // Column doesn't exist — table was never migrated to include target_type.
41
+ }
42
+ }
@@ -126,10 +126,8 @@ export { migrateRenameThreadStartersCheckpoints } from "./181-rename-thread-star
126
126
  export { migrateOAuthProvidersDisplayMetadata } from "./182-oauth-providers-display-metadata.js";
127
127
  export { migrateConversationForkLineage } from "./183-add-conversation-fork-lineage.js";
128
128
  export { migrateLlmRequestLogProvider } from "./184-llm-request-log-provider.js";
129
- export { migrateMemoryBriefState } from "./185-memory-brief-state.js";
130
- export { migrateMemoryArchiveTables } from "./186-memory-archive.js";
131
- export { migrateMemoryReducerCheckpoints } from "./187-memory-reducer-checkpoints.js";
132
129
  export { migrateScheduleQuietFlag } from "./188-schedule-quiet-flag.js";
130
+ export { migrateDropSimplifiedMemory } from "./189-drop-simplified-memory.js";
133
131
  export {
134
132
  MIGRATION_REGISTRY,
135
133
  type MigrationRegistryEntry,
@@ -20,7 +20,7 @@ export interface QdrantClientConfig {
20
20
  }
21
21
 
22
22
  export interface QdrantPointPayload {
23
- target_type: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media";
23
+ target_type: "segment" | "item" | "summary" | "media";
24
24
  target_id: string;
25
25
  text: string;
26
26
  kind?: string;
@@ -230,7 +230,7 @@ export class VellumQdrantClient {
230
230
  }
231
231
 
232
232
  async upsert(
233
- targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
233
+ targetType: "segment" | "item" | "summary" | "media",
234
234
  targetId: string,
235
235
  vector: number[],
236
236
  payload: Omit<QdrantPointPayload, "target_type" | "target_id">,
@@ -324,9 +324,7 @@ export class VellumQdrantClient {
324
324
  async searchWithFilter(
325
325
  vector: number[],
326
326
  limit: number,
327
- targetTypes: Array<
328
- "segment" | "item" | "summary" | "media" | "chunk" | "episode"
329
- >,
327
+ targetTypes: Array<"segment" | "item" | "summary" | "media">,
330
328
  excludeMessageIds?: string[],
331
329
  scopeIds?: string[],
332
330
  ): Promise<QdrantSearchResult[]> {
@@ -349,7 +347,7 @@ export class VellumQdrantClient {
349
347
  },
350
348
  {
351
349
  key: "target_type",
352
- match: { any: ["segment", "summary", "media", "chunk"] },
350
+ match: { any: ["segment", "summary", "media"] },
353
351
  },
354
352
  ],
355
353
  });
@@ -30,9 +30,6 @@ export const conversations = sqliteTable(
30
30
  forkParentMessageId: text("fork_parent_message_id"),
31
31
  isAutoTitle: integer("is_auto_title").notNull().default(1),
32
32
  scheduleJobId: text("schedule_job_id"),
33
- memoryReducedThroughMessageId: text("memory_reduced_through_message_id"),
34
- memoryDirtyTailSinceMessageId: text("memory_dirty_tail_since_message_id"),
35
- memoryLastReducedAt: integer("memory_last_reduced_at"),
36
33
  },
37
34
  (table) => [
38
35
  index("idx_conversations_updated_at").on(table.updatedAt),
@@ -3,8 +3,6 @@ export * from "./contacts.js";
3
3
  export * from "./conversations.js";
4
4
  export * from "./guardian.js";
5
5
  export * from "./infrastructure.js";
6
- export * from "./memory-archive.js";
7
- export * from "./memory-brief.js";
8
6
  export * from "./memory-core.js";
9
7
  export * from "./notifications.js";
10
8
  export * from "./oauth.js";
@@ -10,7 +10,7 @@ import { readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  import { ensureDir, pathExists } from "../util/fs.js";
13
- import { getRootDir } from "../util/platform.js";
13
+ import { getWorkspaceDir } from "../util/platform.js";
14
14
 
15
15
  export interface Draft {
16
16
  id: string;
@@ -25,7 +25,7 @@ export interface Draft {
25
25
  }
26
26
 
27
27
  function getDraftsDir(platform: string): string {
28
- const dir = join(getRootDir(), "workspace", "data", "drafts", platform);
28
+ const dir = join(getWorkspaceDir(), "data", "drafts", platform);
29
29
  ensureDir(dir);
30
30
  return dir;
31
31
  }
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
 
3
3
  import { getConfig } from "../config/loader.js";
4
4
  import { getBundledSkillsDir } from "../config/skills.js";
5
- import { getRootDir } from "../util/platform.js";
5
+ import { getWorkspaceDir } from "../util/platform.js";
6
6
 
7
7
  export interface DefaultRuleTemplate {
8
8
  id: string;
@@ -116,7 +116,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
116
116
  // Workspace prompt files — the agent should always be able to read, edit,
117
117
  // and write these without prompting. Also allow `rm BOOTSTRAP.md` so the
118
118
  // agent can delete it at the end of the onboarding ritual.
119
- const workspaceDir = join(getRootDir(), "workspace").replaceAll("\\", "/");
119
+ const workspaceDir = getWorkspaceDir().replaceAll("\\", "/");
120
120
  const WORKSPACE_PROMPT_FILES = [
121
121
  "IDENTITY.md",
122
122
  "USER.md",
@@ -163,7 +163,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
163
163
  // Skill source directories — writing or editing skill source files should
164
164
  // require explicit user approval so a compromised agent loop cannot silently
165
165
  // modify skill code to escalate privileges.
166
- const managedSkillsDir = join(getRootDir(), "workspace", "skills").replaceAll(
166
+ const managedSkillsDir = join(getWorkspaceDir(), "skills").replaceAll(
167
167
  "\\",
168
168
  "/",
169
169
  );
@@ -33,17 +33,6 @@ export interface AcceptStarterBundleResult {
33
33
  // Helpers
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
- /**
37
- * Resolve the gateway base URL for trust rule requests.
38
- *
39
- * Prefers the `GATEWAY_INTERNAL_URL` env var (set in Docker environments
40
- * where the gateway runs in a separate container), falling back to the
41
- * existing `getGatewayInternalBaseUrl()` helper for local deployments.
42
- */
43
- function getBaseUrl(): string {
44
- return process.env.GATEWAY_INTERNAL_URL ?? getGatewayInternalBaseUrl();
45
- }
46
-
47
36
  function authHeaders(): Record<string, string> {
48
37
  return {
49
38
  Authorization: `Bearer ${mintDaemonDeliveryToken()}`,
@@ -60,7 +49,7 @@ async function request<T>(
60
49
  path: string,
61
50
  body?: unknown,
62
51
  ): Promise<T> {
63
- const url = `${getBaseUrl()}${path}`;
52
+ const url = `${getGatewayInternalBaseUrl()}${path}`;
64
53
  const options: RequestInit = {
65
54
  method,
66
55
  headers: authHeaders(),
@@ -102,7 +91,7 @@ async function request<T>(
102
91
  * Write operations are user-initiated and infrequent, so blocking is acceptable.
103
92
  */
104
93
  function requestSync<T>(method: string, path: string, body?: unknown): T {
105
- const url = `${getBaseUrl()}${path}`;
94
+ const url = `${getGatewayInternalBaseUrl()}${path}`;
106
95
  const headers = authHeaders();
107
96
  const args: string[] = [
108
97
  "curl",
@@ -185,9 +185,10 @@ function backfillDefaults(rules: TrustRule[]): boolean {
185
185
  }
186
186
  }
187
187
 
188
- // Migrate existing default rules whose priority, pattern, decision, or
189
- // allowHighRisk has changed in the template (e.g. host_bash pattern changed
190
- // from '*' to '**', host tool priorities changed from 1000 to 50).
188
+ // Migrate existing default rules whose priority, pattern, scope, decision,
189
+ // or allowHighRisk has changed in the template (e.g. host_bash pattern
190
+ // changed from '*' to '**', host tool priorities changed from 1000 to 50,
191
+ // workspace scope changed from getRootDir()+workspace to getWorkspaceDir()).
191
192
  for (const template of getDefaultRuleTemplates()) {
192
193
  if (existingIds.has(template.id)) {
193
194
  const rule = rules.find((r) => r.id === template.id);
@@ -195,6 +196,7 @@ function backfillDefaults(rules: TrustRule[]): boolean {
195
196
  rule &&
196
197
  (rule.priority !== template.priority ||
197
198
  rule.pattern !== template.pattern ||
199
+ rule.scope !== template.scope ||
198
200
  rule.decision !== template.decision ||
199
201
  rule.allowHighRisk !== template.allowHighRisk)
200
202
  ) {
@@ -205,11 +207,14 @@ function backfillDefaults(rules: TrustRule[]): boolean {
205
207
  newPriority: template.priority,
206
208
  oldPattern: rule.pattern,
207
209
  newPattern: template.pattern,
210
+ oldScope: rule.scope,
211
+ newScope: template.scope,
208
212
  },
209
213
  "Migrated default rule to updated template values",
210
214
  );
211
215
  rule.priority = template.priority;
212
216
  rule.pattern = template.pattern;
217
+ rule.scope = template.scope;
213
218
  rule.decision = template.decision;
214
219
  if (template.allowHighRisk != null) {
215
220
  rule.allowHighRisk = template.allowHighRisk;
@@ -176,6 +176,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
176
176
 
177
177
  // Settings / integrations / identity
178
178
  { endpoint: "identity", scopes: ["settings.read"] },
179
+ { endpoint: "identity/intro", scopes: ["settings.read"] },
179
180
  { endpoint: "brain-graph", scopes: ["settings.read"] },
180
181
  { endpoint: "brain-graph-ui", scopes: ["settings.read"] },
181
182
  { endpoint: "contacts", scopes: ["settings.read"] },
@@ -347,6 +348,10 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
347
348
  { endpoint: "config/embeddings:GET", scopes: ["settings.read"] },
348
349
  { endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
349
350
 
351
+ // Permissions config
352
+ { endpoint: "config/permissions/skip:GET", scopes: ["settings.read"] },
353
+ { endpoint: "config/permissions/skip:PUT", scopes: ["settings.write"] },
354
+
350
355
  // Conversation management
351
356
  { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
352
357
  { endpoint: "conversations/wipe", scopes: ["chat.write"] },
@@ -355,6 +360,9 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
355
360
  // Conversation search
356
361
  { endpoint: "conversations/search", scopes: ["chat.read"] },
357
362
 
363
+ // Conversation starters
364
+ { endpoint: "conversation-starters", scopes: ["chat.read"] },
365
+
358
366
  // Message content
359
367
  { endpoint: "messages/content", scopes: ["chat.read"] },
360
368
  { endpoint: "messages/llm-context", scopes: ["chat.read"] },
@@ -498,3 +506,9 @@ for (const endpoint of INTERNAL_ENDPOINTS) {
498
506
  allowedPrincipalTypes: ["svc_gateway"],
499
507
  });
500
508
  }
509
+
510
+ // Admin control-plane endpoints: gateway-only
511
+ registerPolicy("admin/upgrade-broadcast", {
512
+ requiredScopes: ["internal.write"],
513
+ allowedPrincipalTypes: ["svc_gateway"],
514
+ });
@@ -28,6 +28,22 @@ import type { ScopeProfile, TokenAudience, TokenClaims } from "./types.js";
28
28
 
29
29
  const log = getLogger("token-service");
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Bootstrap sentinel error
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Thrown when the gateway's signing-key bootstrap endpoint returns 403,
37
+ * indicating that bootstrap has already completed (daemon restart case).
38
+ * The caller should fall back to loading the key from disk.
39
+ */
40
+ export class BootstrapAlreadyCompleted extends Error {
41
+ constructor() {
42
+ super("Gateway signing key bootstrap already completed");
43
+ this.name = "BootstrapAlreadyCompleted";
44
+ }
45
+ }
46
+
31
47
  // ---------------------------------------------------------------------------
32
48
  // Signing key management
33
49
  // ---------------------------------------------------------------------------
@@ -78,6 +94,123 @@ export function loadOrCreateSigningKey(): Buffer {
78
94
  return newKey;
79
95
  }
80
96
 
97
+ /**
98
+ * Fetch the shared signing key from the gateway's bootstrap endpoint.
99
+ *
100
+ * Used in Docker mode where the gateway owns the signing key and the daemon
101
+ * must fetch it at startup. Retries up to 30 times with 1s intervals to
102
+ * tolerate gateway startup delays.
103
+ *
104
+ * @returns A 32-byte Buffer containing the signing key.
105
+ * @throws {BootstrapAlreadyCompleted} If the gateway returns 403 (bootstrap
106
+ * already completed — daemon restart case). Caller should fall back to
107
+ * loading the key from disk.
108
+ * @throws {Error} If the gateway is unreachable after all retry attempts.
109
+ */
110
+ export async function fetchSigningKeyFromGateway(): Promise<Buffer> {
111
+ const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
112
+ if (!gatewayUrl) {
113
+ throw new Error("GATEWAY_INTERNAL_URL not set — cannot fetch signing key");
114
+ }
115
+
116
+ const maxAttempts = 30;
117
+ const intervalMs = 1000;
118
+
119
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ let resp: Response | undefined;
121
+ try {
122
+ resp = await fetch(`${gatewayUrl}/internal/signing-key-bootstrap`, {
123
+ signal: AbortSignal.timeout(5000),
124
+ });
125
+ } catch (err) {
126
+ log.warn(
127
+ { err, attempt },
128
+ "Signing key bootstrap: connection failed, retrying",
129
+ );
130
+ await Bun.sleep(intervalMs);
131
+ continue;
132
+ }
133
+
134
+ if (resp.ok) {
135
+ const body = (await resp.json()) as { key: string };
136
+ const keyBuf = Buffer.from(body.key, "hex");
137
+ if (keyBuf.length !== 32) {
138
+ throw new Error(`Invalid signing key length: ${keyBuf.length}`);
139
+ }
140
+ log.info("Signing key fetched from gateway bootstrap endpoint");
141
+ return keyBuf;
142
+ }
143
+
144
+ if (resp.status === 403) {
145
+ // Bootstrap already completed — fall through to file-based load.
146
+ // This happens on daemon restart when the gateway lockfile persists.
147
+ log.info(
148
+ "Gateway signing key bootstrap already completed — loading from disk",
149
+ );
150
+ throw new BootstrapAlreadyCompleted();
151
+ }
152
+
153
+ log.warn(
154
+ { status: resp.status, attempt },
155
+ "Signing key bootstrap: gateway not ready, retrying",
156
+ );
157
+
158
+ await Bun.sleep(intervalMs);
159
+ }
160
+
161
+ throw new Error("Signing key bootstrap: timed out waiting for gateway");
162
+ }
163
+
164
+ /**
165
+ * Persist a signing key to disk using an atomic-write pattern.
166
+ * Used after fetching the key from the gateway so daemon restarts can
167
+ * load it from disk when the gateway returns 403.
168
+ */
169
+ function persistSigningKey(key: Buffer): void {
170
+ const keyPath = getSigningKeyPath();
171
+ const dir = dirname(keyPath);
172
+ if (!existsSync(dir)) {
173
+ mkdirSync(dir, { recursive: true });
174
+ }
175
+ const tmpPath = keyPath + ".tmp." + process.pid;
176
+ writeFileSync(tmpPath, key, { mode: 0o600 });
177
+ renameSync(tmpPath, keyPath);
178
+ chmodSync(keyPath, 0o600);
179
+ }
180
+
181
+ /**
182
+ * Resolve the signing key for the current environment.
183
+ *
184
+ * In Docker mode (IS_CONTAINERIZED=true + GATEWAY_INTERNAL_URL set), fetches
185
+ * the key from the gateway's bootstrap endpoint and persists it locally for
186
+ * restart resilience. On daemon restart (gateway returns 403), falls back to
187
+ * loading the key from disk.
188
+ *
189
+ * In local mode, delegates to the existing file-based loadOrCreateSigningKey().
190
+ */
191
+ export async function resolveSigningKey(): Promise<Buffer> {
192
+ const isContainerized = process.env.IS_CONTAINERIZED === "true";
193
+ const gatewayUrl = process.env.GATEWAY_INTERNAL_URL;
194
+
195
+ if (isContainerized && gatewayUrl) {
196
+ try {
197
+ const key = await fetchSigningKeyFromGateway();
198
+ // Persist locally so daemon restarts (where gateway returns 403) load from disk.
199
+ persistSigningKey(key);
200
+ return key;
201
+ } catch (err) {
202
+ if (err instanceof BootstrapAlreadyCompleted) {
203
+ // Gateway already bootstrapped (daemon restart) — load from disk.
204
+ return loadOrCreateSigningKey();
205
+ }
206
+ throw err;
207
+ }
208
+ }
209
+
210
+ // Local mode: use file-based load/create (unchanged behavior).
211
+ return loadOrCreateSigningKey();
212
+ }
213
+
81
214
  function getSigningKey(): Buffer {
82
215
  if (!_authSigningKey) {
83
216
  if (process.env.NODE_ENV === "test") {
@@ -172,6 +172,7 @@ import { surfaceContentRouteDefinitions } from "./routes/surface-content-routes.
172
172
  import { telemetryRouteDefinitions } from "./routes/telemetry-routes.js";
173
173
  import { traceEventRouteDefinitions } from "./routes/trace-event-routes.js";
174
174
  import { trustRulesRouteDefinitions } from "./routes/trust-rules-routes.js";
175
+ import { upgradeBroadcastRouteDefinitions } from "./routes/upgrade-broadcast-routes.js";
175
176
  import { usageRouteDefinitions } from "./routes/usage-routes.js";
176
177
  import { watchRouteDefinitions } from "./routes/watch-routes.js";
177
178
  import { workItemRouteDefinitions } from "./routes/work-items-routes.js";
@@ -918,6 +919,7 @@ export class RuntimeHttpServer {
918
919
  getCesClient: this.getCesClient,
919
920
  }),
920
921
  ...identityRouteDefinitions(),
922
+ ...upgradeBroadcastRouteDefinitions(),
921
923
  ...debugRouteDefinitions(),
922
924
  ...usageRouteDefinitions(),
923
925
  ...telemetryRouteDefinitions(),