@vellumai/assistant 0.9.0-dev.202606180940.f141ea3 → 0.9.0-dev.202606181324.0804a69
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/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +17 -12
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -3
- package/openapi.yaml +52 -0
- package/package.json +1 -1
- package/src/__tests__/disk-usage.test.ts +65 -0
- package/src/api/index.ts +2 -0
- package/src/api/responses/home.ts +26 -0
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +9 -10
- package/src/home/feed-source-enrichment.test.ts +151 -0
- package/src/home/feed-source-enrichment.ts +176 -0
- package/src/runtime/capabilities.ts +7 -4
- package/src/runtime/effective-capabilities.test.ts +128 -0
- package/src/runtime/effective-capabilities.ts +84 -0
- package/src/runtime/routes/home-feed-routes.ts +8 -3
- package/src/tools/document/document-tool.ts +2 -5
- package/src/tools/executor.ts +5 -3
- package/src/tools/host-terminal/host-shell.ts +5 -4
- package/src/tools/terminal/shell.ts +5 -4
- package/src/util/disk-usage.ts +78 -23
|
@@ -49,8 +49,7 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Hard-exempt internal channels — never subject to PUT policy, omitted from
|
|
52
|
-
* GET list, runtime admission stage short-circuits without floor check
|
|
53
|
-
* gateway kill switch skips the `no_one` check.
|
|
52
|
+
* GET list, runtime admission stage short-circuits without floor check.
|
|
54
53
|
*
|
|
55
54
|
* `platform` / `a2a` are peer/internal channels with no human-trust model.
|
|
56
55
|
*
|
|
@@ -61,8 +60,8 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
|
|
|
61
60
|
* until voice ingress is wired in a follow-up PR. Remove `"phone"` from this
|
|
62
61
|
* set once the voice path enforces admission.
|
|
63
62
|
*
|
|
64
|
-
* `vellum` is
|
|
65
|
-
*
|
|
63
|
+
* `vellum` is NOT exempt — its floor is still enforced at runtime — but it is
|
|
64
|
+
* hidden from the configurable UI; see {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
|
|
66
65
|
*/
|
|
67
66
|
export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
|
|
68
67
|
"platform",
|
|
@@ -75,18 +74,24 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
|
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
/**
|
|
78
|
-
* Channels
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
77
|
+
* Channels omitted from the Channel Trust Floors list (GET) and rejected on
|
|
78
|
+
* PUT/DELETE — managed automatically at their seed default, not user
|
|
79
|
+
* configurable. Unlike {@link ADMISSION_POLICY_EXEMPT_CHANNELS} they are still
|
|
80
|
+
* enforced at runtime, so hiding a real inbound channel like `whatsapp` never
|
|
81
|
+
* silently disables its admission floor check. The startup seed re-pins any
|
|
82
|
+
* drifted row so a stale floor (e.g. a legacy `no_one`) can't strand a channel
|
|
83
|
+
* the user can no longer see.
|
|
84
|
+
*
|
|
85
|
+
* `vellum` is the local desktop/web client surface; the guardian is always
|
|
86
|
+
* max-rank there, so the seed default admits them regardless of the floor.
|
|
83
87
|
*/
|
|
84
|
-
export const
|
|
88
|
+
export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
|
|
85
89
|
"vellum",
|
|
90
|
+
"whatsapp",
|
|
86
91
|
]);
|
|
87
92
|
|
|
88
|
-
export function
|
|
89
|
-
return
|
|
93
|
+
export function isAdmissionPolicyHiddenChannel(channelType: string): boolean {
|
|
94
|
+
return ADMISSION_POLICY_HIDDEN_CHANNELS.has(channelType);
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
export function isAdmissionPolicy(value: unknown): value is AdmissionPolicy {
|
|
@@ -56,17 +56,17 @@ export type { IpcRequest, IpcResponse, Logger } from "./types.js";
|
|
|
56
56
|
|
|
57
57
|
export { noopLogger } from "./types.js";
|
|
58
58
|
|
|
59
|
-
// Admission policy contract (gateway → daemon) — Zod schemas + derived types +
|
|
59
|
+
// Admission policy contract (gateway → daemon) — Zod schemas + derived types + channel sets
|
|
60
60
|
export {
|
|
61
61
|
ADMISSION_FLOOR,
|
|
62
62
|
ADMISSION_POLICY_DEFAULT,
|
|
63
63
|
ADMISSION_POLICY_EXEMPT_CHANNELS,
|
|
64
|
+
ADMISSION_POLICY_HIDDEN_CHANNELS,
|
|
64
65
|
ADMISSION_POLICY_VALUES,
|
|
65
66
|
AdmissionPolicySchema,
|
|
66
67
|
isAdmissionPolicy,
|
|
67
68
|
isAdmissionPolicyExemptChannel,
|
|
68
|
-
|
|
69
|
-
KILL_SWITCH_FORBIDDEN_CHANNELS,
|
|
69
|
+
isAdmissionPolicyHiddenChannel,
|
|
70
70
|
} from "./admission-policy-contract.js";
|
|
71
71
|
|
|
72
72
|
export type { AdmissionPolicy } from "./admission-policy-contract.js";
|
package/openapi.yaml
CHANGED
|
@@ -12116,6 +12116,19 @@ paths:
|
|
|
12116
12116
|
propertyNames:
|
|
12117
12117
|
type: string
|
|
12118
12118
|
additionalProperties: {}
|
|
12119
|
+
sourceType:
|
|
12120
|
+
type: string
|
|
12121
|
+
enum:
|
|
12122
|
+
- heartbeat
|
|
12123
|
+
- memory_consolidation
|
|
12124
|
+
- schedule
|
|
12125
|
+
- auto_analysis
|
|
12126
|
+
- user
|
|
12127
|
+
- other
|
|
12128
|
+
sourceKey:
|
|
12129
|
+
type: string
|
|
12130
|
+
sourceLabel:
|
|
12131
|
+
type: string
|
|
12119
12132
|
createdAt:
|
|
12120
12133
|
type: string
|
|
12121
12134
|
required:
|
|
@@ -12296,6 +12309,19 @@ paths:
|
|
|
12296
12309
|
propertyNames:
|
|
12297
12310
|
type: string
|
|
12298
12311
|
additionalProperties: {}
|
|
12312
|
+
sourceType:
|
|
12313
|
+
type: string
|
|
12314
|
+
enum:
|
|
12315
|
+
- heartbeat
|
|
12316
|
+
- memory_consolidation
|
|
12317
|
+
- schedule
|
|
12318
|
+
- auto_analysis
|
|
12319
|
+
- user
|
|
12320
|
+
- other
|
|
12321
|
+
sourceKey:
|
|
12322
|
+
type: string
|
|
12323
|
+
sourceLabel:
|
|
12324
|
+
type: string
|
|
12299
12325
|
createdAt:
|
|
12300
12326
|
type: string
|
|
12301
12327
|
required:
|
|
@@ -12508,6 +12534,19 @@ paths:
|
|
|
12508
12534
|
propertyNames:
|
|
12509
12535
|
type: string
|
|
12510
12536
|
additionalProperties: {}
|
|
12537
|
+
sourceType:
|
|
12538
|
+
type: string
|
|
12539
|
+
enum:
|
|
12540
|
+
- heartbeat
|
|
12541
|
+
- memory_consolidation
|
|
12542
|
+
- schedule
|
|
12543
|
+
- auto_analysis
|
|
12544
|
+
- user
|
|
12545
|
+
- other
|
|
12546
|
+
sourceKey:
|
|
12547
|
+
type: string
|
|
12548
|
+
sourceLabel:
|
|
12549
|
+
type: string
|
|
12511
12550
|
createdAt:
|
|
12512
12551
|
type: string
|
|
12513
12552
|
required:
|
|
@@ -17936,6 +17975,19 @@ paths:
|
|
|
17936
17975
|
propertyNames:
|
|
17937
17976
|
type: string
|
|
17938
17977
|
additionalProperties: {}
|
|
17978
|
+
sourceType:
|
|
17979
|
+
type: string
|
|
17980
|
+
enum:
|
|
17981
|
+
- heartbeat
|
|
17982
|
+
- memory_consolidation
|
|
17983
|
+
- schedule
|
|
17984
|
+
- auto_analysis
|
|
17985
|
+
- user
|
|
17986
|
+
- other
|
|
17987
|
+
sourceKey:
|
|
17988
|
+
type: string
|
|
17989
|
+
sourceLabel:
|
|
17990
|
+
type: string
|
|
17939
17991
|
createdAt:
|
|
17940
17992
|
type: string
|
|
17941
17993
|
required:
|
package/package.json
CHANGED
|
@@ -6,6 +6,8 @@ const GIB = 1024 * MIB;
|
|
|
6
6
|
let existingPaths = new Set<string>();
|
|
7
7
|
const workspaceDir = "/workspace";
|
|
8
8
|
let minikubeStorageSize: string | undefined;
|
|
9
|
+
let isContainerized = false;
|
|
10
|
+
let isPlatform = false;
|
|
9
11
|
let statfsResult = {
|
|
10
12
|
bsize: 4096,
|
|
11
13
|
blocks: 0,
|
|
@@ -34,6 +36,8 @@ mock.module("node:child_process", () => ({
|
|
|
34
36
|
|
|
35
37
|
mock.module("../config/env-registry.js", () => ({
|
|
36
38
|
getMinikubeStorageSize: () => minikubeStorageSize,
|
|
39
|
+
getIsContainerized: () => isContainerized,
|
|
40
|
+
getIsPlatform: () => isPlatform,
|
|
37
41
|
}));
|
|
38
42
|
|
|
39
43
|
mock.module("../util/platform.js", () => ({
|
|
@@ -55,6 +59,8 @@ describe("disk usage sampler", () => {
|
|
|
55
59
|
beforeEach(() => {
|
|
56
60
|
existingPaths = new Set([workspaceDir]);
|
|
57
61
|
minikubeStorageSize = undefined;
|
|
62
|
+
isContainerized = false;
|
|
63
|
+
isPlatform = false;
|
|
58
64
|
statfsResult = statfsFor(100 * MIB, 25 * MIB);
|
|
59
65
|
spawnResult = { status: 0, stdout: "" };
|
|
60
66
|
spawnCalls = [];
|
|
@@ -119,6 +125,65 @@ describe("disk usage sampler", () => {
|
|
|
119
125
|
]);
|
|
120
126
|
});
|
|
121
127
|
|
|
128
|
+
test("measures workspace du usage on a local Docker hatch", () => {
|
|
129
|
+
isContainerized = true;
|
|
130
|
+
isPlatform = false;
|
|
131
|
+
statfsResult = statfsFor(100 * GIB, 40 * GIB);
|
|
132
|
+
spawnResult = {
|
|
133
|
+
status: 0,
|
|
134
|
+
stdout: `${2 * GIB}\t/workspace\n`,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const usage = getDiskUsageInfo();
|
|
138
|
+
|
|
139
|
+
// Used reflects only the workspace (du), free reflects the host headroom
|
|
140
|
+
// the volume can grow into, and total is their sum.
|
|
141
|
+
expect(usage).toEqual({
|
|
142
|
+
path: "/workspace",
|
|
143
|
+
totalMb: 2048 + 40960,
|
|
144
|
+
usedMb: 2048,
|
|
145
|
+
freeMb: 40960,
|
|
146
|
+
});
|
|
147
|
+
expect(spawnCalls).toEqual([
|
|
148
|
+
{ command: "du", args: ["-sb", "/workspace"] },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("does not use du for platform-managed containerized instances", () => {
|
|
153
|
+
isContainerized = true;
|
|
154
|
+
isPlatform = true;
|
|
155
|
+
statfsResult = statfsFor(10 * GIB, 6 * GIB);
|
|
156
|
+
|
|
157
|
+
const usage = getDiskUsageInfo();
|
|
158
|
+
|
|
159
|
+
expect(usage).toEqual({
|
|
160
|
+
path: "/workspace",
|
|
161
|
+
totalMb: 10240,
|
|
162
|
+
usedMb: 4096,
|
|
163
|
+
freeMb: 6144,
|
|
164
|
+
});
|
|
165
|
+
expect(spawnCalls).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("falls back to statfs when du fails on a local Docker hatch", () => {
|
|
169
|
+
isContainerized = true;
|
|
170
|
+
isPlatform = false;
|
|
171
|
+
statfsResult = statfsFor(100 * GIB, 40 * GIB);
|
|
172
|
+
spawnResult = { status: 1, stdout: "" };
|
|
173
|
+
|
|
174
|
+
const usage = getDiskUsageInfo();
|
|
175
|
+
|
|
176
|
+
expect(usage).toEqual({
|
|
177
|
+
path: "/workspace",
|
|
178
|
+
totalMb: 102400,
|
|
179
|
+
usedMb: 61440,
|
|
180
|
+
freeMb: 40960,
|
|
181
|
+
});
|
|
182
|
+
expect(spawnCalls).toEqual([
|
|
183
|
+
{ command: "du", args: ["-sb", "/workspace"] },
|
|
184
|
+
]);
|
|
185
|
+
});
|
|
186
|
+
|
|
122
187
|
test("returns null for malformed Kubernetes memory strings", () => {
|
|
123
188
|
expect(parseK8sMemoryBytes("")).toBeNull();
|
|
124
189
|
expect(parseK8sMemoryBytes("abc")).toBeNull();
|
package/src/api/index.ts
CHANGED
|
@@ -55,6 +55,25 @@ export const FeedItemCategorySchema = z.enum([
|
|
|
55
55
|
]);
|
|
56
56
|
export type FeedItemCategory = z.infer<typeof FeedItemCategorySchema>;
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Producer of a feed item's source conversation — lets clients filter the
|
|
60
|
+
* activity feed by what generated each notification: the periodic
|
|
61
|
+
* heartbeat, a memory-consolidation pass, a recurring schedule, an
|
|
62
|
+
* auto-analysis run, etc. Derived at read time from the source
|
|
63
|
+
* conversation's `source` column (see `home/feed-source-enrichment.ts`).
|
|
64
|
+
* Individual schedules are distinguished by `sourceKey`/`sourceLabel`, not
|
|
65
|
+
* this coarse type.
|
|
66
|
+
*/
|
|
67
|
+
export const FeedItemSourceTypeSchema = z.enum([
|
|
68
|
+
"heartbeat",
|
|
69
|
+
"memory_consolidation",
|
|
70
|
+
"schedule",
|
|
71
|
+
"auto_analysis",
|
|
72
|
+
"user",
|
|
73
|
+
"other",
|
|
74
|
+
]);
|
|
75
|
+
export type FeedItemSourceType = z.infer<typeof FeedItemSourceTypeSchema>;
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* A single action button attached to a feed item.
|
|
60
79
|
*
|
|
@@ -120,6 +139,13 @@ export const FeedItemSchema = z.object({
|
|
|
120
139
|
noteworthy: z.boolean().optional(),
|
|
121
140
|
fromAssistant: z.boolean().optional(),
|
|
122
141
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
142
|
+
// Source-conversation classification, enriched at read time. `sourceKey`
|
|
143
|
+
// is the stable filter id — `schedule:<id>` for schedules so each filters
|
|
144
|
+
// separately, otherwise the `sourceType`. `sourceLabel` is the display
|
|
145
|
+
// string (a schedule's name, or a static label like "Heartbeat").
|
|
146
|
+
sourceType: FeedItemSourceTypeSchema.optional(),
|
|
147
|
+
sourceKey: z.string().optional(),
|
|
148
|
+
sourceLabel: z.string().optional(),
|
|
123
149
|
createdAt: z.string(),
|
|
124
150
|
});
|
|
125
151
|
export type FeedItem = z.infer<typeof FeedItemSchema>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isArchiveBySenderAuthorized } from "../../../../runtime/effective-capabilities.js";
|
|
2
2
|
import type {
|
|
3
3
|
ToolContext,
|
|
4
4
|
ToolExecutionResult,
|
|
@@ -9,15 +9,14 @@ export async function run(
|
|
|
9
9
|
input: Record<string, unknown>,
|
|
10
10
|
context: ToolContext,
|
|
11
11
|
): Promise<ToolExecutionResult> {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
) {
|
|
12
|
+
const authorized = isArchiveBySenderAuthorized({
|
|
13
|
+
trustClass: context.trustClass,
|
|
14
|
+
triggeredBySurfaceAction: context.triggeredBySurfaceAction,
|
|
15
|
+
batchAuthorizedByTask: context.batchAuthorizedByTask,
|
|
16
|
+
approvedViaPrompt: context.approvedViaPrompt,
|
|
17
|
+
userApproved: input.user_approved === true,
|
|
18
|
+
});
|
|
19
|
+
if (!authorized) {
|
|
21
20
|
return err(
|
|
22
21
|
"This tool requires either a surface action or a scheduled task run with this tool in required_tools. Present results in a selection table with action buttons and wait for the user to click before proceeding.",
|
|
23
22
|
);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { type FeedItem } from "../api/responses/home.js";
|
|
4
|
+
import {
|
|
5
|
+
classifyConversationSource,
|
|
6
|
+
enrichFeedItemsWithSource,
|
|
7
|
+
type FeedSourceEnrichmentDeps,
|
|
8
|
+
} from "./feed-source-enrichment.js";
|
|
9
|
+
|
|
10
|
+
function makeItem(partial: Partial<FeedItem> & Pick<FeedItem, "id">): FeedItem {
|
|
11
|
+
return {
|
|
12
|
+
type: "notification",
|
|
13
|
+
priority: 50,
|
|
14
|
+
summary: "summary",
|
|
15
|
+
timestamp: "2026-06-18T00:00:00.000Z",
|
|
16
|
+
createdAt: "2026-06-18T00:00:00.000Z",
|
|
17
|
+
status: "new",
|
|
18
|
+
...partial,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("classifyConversationSource", () => {
|
|
23
|
+
it("maps each known producer source to its coarse type", () => {
|
|
24
|
+
expect(classifyConversationSource("heartbeat")).toBe("heartbeat");
|
|
25
|
+
expect(classifyConversationSource("memory-retrospective")).toBe(
|
|
26
|
+
"memory_consolidation",
|
|
27
|
+
);
|
|
28
|
+
expect(classifyConversationSource("memory-retrospective-fork")).toBe(
|
|
29
|
+
"memory_consolidation",
|
|
30
|
+
);
|
|
31
|
+
expect(classifyConversationSource("schedule")).toBe("schedule");
|
|
32
|
+
expect(classifyConversationSource("auto-analysis")).toBe("auto_analysis");
|
|
33
|
+
expect(classifyConversationSource("user")).toBe("user");
|
|
34
|
+
expect(classifyConversationSource("home-feed")).toBe("user");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls through to 'other' for unknown / paired-delivery / empty sources", () => {
|
|
38
|
+
expect(classifyConversationSource("notification")).toBe("other");
|
|
39
|
+
expect(classifyConversationSource("something-else")).toBe("other");
|
|
40
|
+
expect(classifyConversationSource(null)).toBe("other");
|
|
41
|
+
expect(classifyConversationSource(undefined)).toBe("other");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("enrichFeedItemsWithSource", () => {
|
|
46
|
+
const deps = (
|
|
47
|
+
conv: Record<string, { source: string; scheduleJobId: string | null }>,
|
|
48
|
+
schedules: Record<string, string> = {},
|
|
49
|
+
): FeedSourceEnrichmentDeps => ({
|
|
50
|
+
getConversationRow: (id) => conv[id] ?? null,
|
|
51
|
+
getScheduleName: (id) => schedules[id] ?? null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("classifies a heartbeat item with a static label", () => {
|
|
55
|
+
const [item] = enrichFeedItemsWithSource(
|
|
56
|
+
[makeItem({ id: "n1", conversationId: "c1" })],
|
|
57
|
+
deps({ c1: { source: "heartbeat", scheduleJobId: null } }),
|
|
58
|
+
);
|
|
59
|
+
expect(item.sourceType).toBe("heartbeat");
|
|
60
|
+
expect(item.sourceKey).toBe("heartbeat");
|
|
61
|
+
expect(item.sourceLabel).toBe("Heartbeat");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("classifies a memory-consolidation item", () => {
|
|
65
|
+
const [item] = enrichFeedItemsWithSource(
|
|
66
|
+
[makeItem({ id: "n2", conversationId: "c2" })],
|
|
67
|
+
deps({ c2: { source: "memory-retrospective", scheduleJobId: null } }),
|
|
68
|
+
);
|
|
69
|
+
expect(item.sourceType).toBe("memory_consolidation");
|
|
70
|
+
expect(item.sourceKey).toBe("memory_consolidation");
|
|
71
|
+
expect(item.sourceLabel).toBe("Memory consolidation");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("gives distinct keys and names to two different schedules", () => {
|
|
75
|
+
const [a, b] = enrichFeedItemsWithSource(
|
|
76
|
+
[
|
|
77
|
+
makeItem({ id: "n3", conversationId: "cA" }),
|
|
78
|
+
makeItem({ id: "n4", conversationId: "cB" }),
|
|
79
|
+
],
|
|
80
|
+
deps(
|
|
81
|
+
{
|
|
82
|
+
cA: { source: "schedule", scheduleJobId: "sched-A" },
|
|
83
|
+
cB: { source: "schedule", scheduleJobId: "sched-B" },
|
|
84
|
+
},
|
|
85
|
+
{ "sched-A": "Morning digest", "sched-B": "Evening recap" },
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
expect(a.sourceType).toBe("schedule");
|
|
89
|
+
expect(a.sourceKey).toBe("schedule:sched-A");
|
|
90
|
+
expect(a.sourceLabel).toBe("Morning digest");
|
|
91
|
+
// scheduleId recovered from the conversation row is surfaced in metadata.
|
|
92
|
+
expect(a.metadata?.scheduleId).toBe("sched-A");
|
|
93
|
+
|
|
94
|
+
expect(b.sourceKey).toBe("schedule:sched-B");
|
|
95
|
+
expect(b.sourceLabel).toBe("Evening recap");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("prefers an explicit metadata.scheduleId over the conversation row", () => {
|
|
99
|
+
const [item] = enrichFeedItemsWithSource(
|
|
100
|
+
[
|
|
101
|
+
makeItem({
|
|
102
|
+
id: "n5",
|
|
103
|
+
conversationId: "cX",
|
|
104
|
+
metadata: { scheduleId: "from-payload" },
|
|
105
|
+
}),
|
|
106
|
+
],
|
|
107
|
+
deps(
|
|
108
|
+
{ cX: { source: "schedule", scheduleJobId: "from-row" } },
|
|
109
|
+
{ "from-payload": "Payload schedule" },
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
expect(item.sourceKey).toBe("schedule:from-payload");
|
|
113
|
+
expect(item.sourceLabel).toBe("Payload schedule");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("labels a schedule with no resolvable name as 'Scheduled'", () => {
|
|
117
|
+
const [item] = enrichFeedItemsWithSource(
|
|
118
|
+
[makeItem({ id: "n6", conversationId: "cY" })],
|
|
119
|
+
deps({ cY: { source: "schedule", scheduleJobId: "gone" } }),
|
|
120
|
+
);
|
|
121
|
+
expect(item.sourceKey).toBe("schedule:gone");
|
|
122
|
+
expect(item.sourceLabel).toBe("Scheduled");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("classifies items with no source conversation as 'other'", () => {
|
|
126
|
+
const [item] = enrichFeedItemsWithSource(
|
|
127
|
+
[makeItem({ id: "n7" })],
|
|
128
|
+
deps({}),
|
|
129
|
+
);
|
|
130
|
+
expect(item.sourceType).toBe("other");
|
|
131
|
+
expect(item.sourceKey).toBe("other");
|
|
132
|
+
expect(item.sourceLabel).toBe("Other");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resolves each distinct conversation at most once", () => {
|
|
136
|
+
const calls: string[] = [];
|
|
137
|
+
enrichFeedItemsWithSource(
|
|
138
|
+
[
|
|
139
|
+
makeItem({ id: "n8", conversationId: "dup" }),
|
|
140
|
+
makeItem({ id: "n9", conversationId: "dup" }),
|
|
141
|
+
],
|
|
142
|
+
{
|
|
143
|
+
getConversationRow: (id) => {
|
|
144
|
+
calls.push(id);
|
|
145
|
+
return { source: "heartbeat", scheduleJobId: null };
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
expect(calls).toEqual(["dup"]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-time enrichment of home-feed items with their source-conversation
|
|
3
|
+
* classification.
|
|
4
|
+
*
|
|
5
|
+
* The activity feed surfaces notifications produced by many background
|
|
6
|
+
* flows — the periodic heartbeat, memory-consolidation passes, each
|
|
7
|
+
* recurring schedule, auto-analysis runs, etc. Clients let the user filter
|
|
8
|
+
* the feed by that producer, so every item is tagged with:
|
|
9
|
+
*
|
|
10
|
+
* - `sourceType` — coarse producer category.
|
|
11
|
+
* - `sourceKey` — stable filter id (`schedule:<id>` per schedule,
|
|
12
|
+
* otherwise the `sourceType`).
|
|
13
|
+
* - `sourceLabel` — human-readable display (a schedule's name, or a
|
|
14
|
+
* static label such as "Heartbeat").
|
|
15
|
+
*
|
|
16
|
+
* The classification is derived from the source conversation's `source`
|
|
17
|
+
* column (and, for schedules, its name) rather than persisted onto the
|
|
18
|
+
* feed item. Resolving at read time keeps labels fresh across schedule
|
|
19
|
+
* renames and retroactively classifies items written before this feature
|
|
20
|
+
* existed, with no migration.
|
|
21
|
+
*
|
|
22
|
+
* The lookups are injectable so the enrichment can be unit-tested without
|
|
23
|
+
* a live database; production callers use the defaults.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
type FeedItem,
|
|
28
|
+
type FeedItemSourceType,
|
|
29
|
+
} from "../api/responses/home.js";
|
|
30
|
+
import { AUTO_ANALYSIS_SOURCE } from "../memory/auto-analysis-constants.js";
|
|
31
|
+
import { getConversation } from "../memory/conversation-crud.js";
|
|
32
|
+
import {
|
|
33
|
+
MEMORY_RETROSPECTIVE_FORK_SOURCE,
|
|
34
|
+
MEMORY_RETROSPECTIVE_SOURCE,
|
|
35
|
+
} from "../memory/memory-retrospective-constants.js";
|
|
36
|
+
import { getSchedule } from "../schedule/schedule-store.js";
|
|
37
|
+
|
|
38
|
+
/** Minimal source-conversation shape the enrichment needs. */
|
|
39
|
+
interface ConversationSourceRow {
|
|
40
|
+
source: string;
|
|
41
|
+
scheduleJobId: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FeedSourceEnrichmentDeps {
|
|
45
|
+
/** Resolve the source columns for a conversation, or null when missing. */
|
|
46
|
+
getConversationRow?: (id: string) => ConversationSourceRow | null;
|
|
47
|
+
/** Resolve a schedule's display name, or null when missing. */
|
|
48
|
+
getScheduleName?: (id: string) => string | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Map a conversation's `source` column to a coarse feed source type.
|
|
53
|
+
* Mechanical mapping over the known producer sources; anything else
|
|
54
|
+
* (including the broadcaster's paired `"notification"` delivery
|
|
55
|
+
* conversations) falls through to `"other"`.
|
|
56
|
+
*/
|
|
57
|
+
export function classifyConversationSource(
|
|
58
|
+
source: string | null | undefined,
|
|
59
|
+
): FeedItemSourceType {
|
|
60
|
+
switch (source) {
|
|
61
|
+
case "heartbeat":
|
|
62
|
+
return "heartbeat";
|
|
63
|
+
case MEMORY_RETROSPECTIVE_SOURCE:
|
|
64
|
+
case MEMORY_RETROSPECTIVE_FORK_SOURCE:
|
|
65
|
+
return "memory_consolidation";
|
|
66
|
+
case "schedule":
|
|
67
|
+
return "schedule";
|
|
68
|
+
case AUTO_ANALYSIS_SOURCE:
|
|
69
|
+
return "auto_analysis";
|
|
70
|
+
case "user":
|
|
71
|
+
case "home-feed":
|
|
72
|
+
return "user";
|
|
73
|
+
default:
|
|
74
|
+
return "other";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Static display labels for non-schedule source types. */
|
|
79
|
+
const STATIC_SOURCE_LABELS: Record<
|
|
80
|
+
Exclude<FeedItemSourceType, "schedule">,
|
|
81
|
+
string
|
|
82
|
+
> = {
|
|
83
|
+
heartbeat: "Heartbeat",
|
|
84
|
+
memory_consolidation: "Memory consolidation",
|
|
85
|
+
auto_analysis: "Auto-analysis",
|
|
86
|
+
user: "Conversation",
|
|
87
|
+
other: "Other",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function defaultGetConversationRow(id: string): ConversationSourceRow | null {
|
|
91
|
+
try {
|
|
92
|
+
const row = getConversation(id);
|
|
93
|
+
return row
|
|
94
|
+
? { source: row.source, scheduleJobId: row.scheduleJobId }
|
|
95
|
+
: null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function defaultGetScheduleName(id: string): string | null {
|
|
102
|
+
try {
|
|
103
|
+
return getSchedule(id)?.name ?? null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return a copy of each feed item enriched with `sourceType`, `sourceKey`,
|
|
111
|
+
* and `sourceLabel`. Conversation and schedule lookups are memoized across
|
|
112
|
+
* the batch so each distinct source is resolved at most once.
|
|
113
|
+
*/
|
|
114
|
+
export function enrichFeedItemsWithSource(
|
|
115
|
+
items: FeedItem[],
|
|
116
|
+
deps: FeedSourceEnrichmentDeps = {},
|
|
117
|
+
): FeedItem[] {
|
|
118
|
+
if (items.length === 0) return items;
|
|
119
|
+
|
|
120
|
+
const getConversationRow =
|
|
121
|
+
deps.getConversationRow ?? defaultGetConversationRow;
|
|
122
|
+
const getScheduleName = deps.getScheduleName ?? defaultGetScheduleName;
|
|
123
|
+
|
|
124
|
+
const convCache = new Map<string, ConversationSourceRow | null>();
|
|
125
|
+
const resolveConv = (id: string): ConversationSourceRow | null => {
|
|
126
|
+
if (!convCache.has(id)) convCache.set(id, getConversationRow(id));
|
|
127
|
+
return convCache.get(id) ?? null;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const scheduleNameCache = new Map<string, string | null>();
|
|
131
|
+
const resolveScheduleName = (id: string): string | null => {
|
|
132
|
+
if (!scheduleNameCache.has(id)) {
|
|
133
|
+
scheduleNameCache.set(id, getScheduleName(id));
|
|
134
|
+
}
|
|
135
|
+
return scheduleNameCache.get(id) ?? null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return items.map((item) => {
|
|
139
|
+
const row = item.conversationId ? resolveConv(item.conversationId) : null;
|
|
140
|
+
const sourceType = classifyConversationSource(row?.source);
|
|
141
|
+
|
|
142
|
+
const metadataScheduleId =
|
|
143
|
+
typeof item.metadata?.scheduleId === "string"
|
|
144
|
+
? item.metadata.scheduleId
|
|
145
|
+
: undefined;
|
|
146
|
+
const scheduleId = metadataScheduleId ?? row?.scheduleJobId ?? undefined;
|
|
147
|
+
|
|
148
|
+
let sourceKey: string;
|
|
149
|
+
let sourceLabel: string;
|
|
150
|
+
if (sourceType === "schedule" && scheduleId) {
|
|
151
|
+
sourceKey = `schedule:${scheduleId}`;
|
|
152
|
+
sourceLabel = resolveScheduleName(scheduleId) ?? "Scheduled";
|
|
153
|
+
} else if (sourceType === "schedule") {
|
|
154
|
+
sourceKey = "schedule";
|
|
155
|
+
sourceLabel = "Scheduled";
|
|
156
|
+
} else {
|
|
157
|
+
sourceKey = sourceType;
|
|
158
|
+
sourceLabel = STATIC_SOURCE_LABELS[sourceType];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Surface the scheduleId in metadata when it was recovered from the
|
|
162
|
+
// source conversation so clients have a single place to read it.
|
|
163
|
+
const metadata =
|
|
164
|
+
scheduleId !== undefined && metadataScheduleId === undefined
|
|
165
|
+
? { ...(item.metadata ?? {}), scheduleId }
|
|
166
|
+
: item.metadata;
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...item,
|
|
170
|
+
sourceType,
|
|
171
|
+
sourceKey,
|
|
172
|
+
sourceLabel,
|
|
173
|
+
...(metadata ? { metadata } : {}),
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
* is the "what they may do once inside" axis.
|
|
18
18
|
*
|
|
19
19
|
* Context-dependent decisions (interactivity routing, self-approval races,
|
|
20
|
-
* channel-specific overrides) COMPOSE these primitives with runtime context
|
|
21
|
-
*
|
|
20
|
+
* channel-specific overrides) COMPOSE these primitives with runtime context.
|
|
21
|
+
* They are not encoded in the table; named composition helpers live in
|
|
22
|
+
* `effective-capabilities.ts` (and `resolveRoutingState` in
|
|
23
|
+
* `trust-context-resolver.ts`).
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import type { TrustClass } from "./actor-trust-resolver.js";
|
|
@@ -76,8 +78,9 @@ export interface CapabilitySet {
|
|
|
76
78
|
*/
|
|
77
79
|
canAccessMemory: boolean;
|
|
78
80
|
/**
|
|
79
|
-
* May perform privileged (non-conversation-scoped) document operations
|
|
80
|
-
*
|
|
81
|
+
* May perform privileged (non-conversation-scoped) document operations from
|
|
82
|
+
* trust class alone. The effective decision also honors privileged channels —
|
|
83
|
+
* see `canActOnPrivilegedDocuments` in `effective-capabilities.ts`.
|
|
81
84
|
*/
|
|
82
85
|
canAccessPrivilegedDocuments: boolean;
|
|
83
86
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
canActOnPrivilegedDocuments,
|
|
5
|
+
isArchiveBySenderAuthorized,
|
|
6
|
+
isUntrustedShellLockdownActive,
|
|
7
|
+
} from "./effective-capabilities.js";
|
|
8
|
+
|
|
9
|
+
describe("canActOnPrivilegedDocuments", () => {
|
|
10
|
+
test("guardian is privileged regardless of channel", () => {
|
|
11
|
+
expect(canActOnPrivilegedDocuments({ trustClass: "guardian" })).toBe(true);
|
|
12
|
+
expect(
|
|
13
|
+
canActOnPrivilegedDocuments({
|
|
14
|
+
trustClass: "guardian",
|
|
15
|
+
executionChannel: "telegram",
|
|
16
|
+
}),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("non-guardian is privileged only on a privileged channel", () => {
|
|
21
|
+
expect(
|
|
22
|
+
canActOnPrivilegedDocuments({
|
|
23
|
+
trustClass: "trusted_contact",
|
|
24
|
+
executionChannel: "telegram",
|
|
25
|
+
}),
|
|
26
|
+
).toBe(false);
|
|
27
|
+
expect(
|
|
28
|
+
canActOnPrivilegedDocuments({
|
|
29
|
+
trustClass: "trusted_contact",
|
|
30
|
+
executionChannel: "vellum",
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("the vellum channel grants access even to unknown actors", () => {
|
|
36
|
+
expect(
|
|
37
|
+
canActOnPrivilegedDocuments({
|
|
38
|
+
trustClass: "unknown",
|
|
39
|
+
executionChannel: "vellum",
|
|
40
|
+
}),
|
|
41
|
+
).toBe(true);
|
|
42
|
+
expect(canActOnPrivilegedDocuments({ trustClass: "unknown" })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("isUntrustedShellLockdownActive", () => {
|
|
47
|
+
test("inactive when the lockdown flag is off", () => {
|
|
48
|
+
expect(
|
|
49
|
+
isUntrustedShellLockdownActive({
|
|
50
|
+
trustClass: "unknown",
|
|
51
|
+
lockdownEnabled: false,
|
|
52
|
+
}),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("inactive for guardians even when the flag is on", () => {
|
|
57
|
+
expect(
|
|
58
|
+
isUntrustedShellLockdownActive({
|
|
59
|
+
trustClass: "guardian",
|
|
60
|
+
lockdownEnabled: true,
|
|
61
|
+
}),
|
|
62
|
+
).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("active for non-unsandboxed actors when the flag is on", () => {
|
|
66
|
+
expect(
|
|
67
|
+
isUntrustedShellLockdownActive({
|
|
68
|
+
trustClass: "trusted_contact",
|
|
69
|
+
lockdownEnabled: true,
|
|
70
|
+
}),
|
|
71
|
+
).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
isUntrustedShellLockdownActive({
|
|
74
|
+
trustClass: "unknown",
|
|
75
|
+
lockdownEnabled: true,
|
|
76
|
+
}),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("isArchiveBySenderAuthorized", () => {
|
|
82
|
+
test("any non-self authorization path suffices", () => {
|
|
83
|
+
expect(
|
|
84
|
+
isArchiveBySenderAuthorized({
|
|
85
|
+
trustClass: "unknown",
|
|
86
|
+
triggeredBySurfaceAction: true,
|
|
87
|
+
}),
|
|
88
|
+
).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
isArchiveBySenderAuthorized({
|
|
91
|
+
trustClass: "unknown",
|
|
92
|
+
batchAuthorizedByTask: true,
|
|
93
|
+
}),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
expect(
|
|
96
|
+
isArchiveBySenderAuthorized({
|
|
97
|
+
trustClass: "unknown",
|
|
98
|
+
approvedViaPrompt: true,
|
|
99
|
+
}),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("user_approved self-authorizes only for a permitted trust class", () => {
|
|
104
|
+
expect(
|
|
105
|
+
isArchiveBySenderAuthorized({
|
|
106
|
+
trustClass: "guardian",
|
|
107
|
+
userApproved: true,
|
|
108
|
+
}),
|
|
109
|
+
).toBe(true);
|
|
110
|
+
expect(
|
|
111
|
+
isArchiveBySenderAuthorized({
|
|
112
|
+
trustClass: "trusted_contact",
|
|
113
|
+
userApproved: true,
|
|
114
|
+
}),
|
|
115
|
+
).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("unauthorized when no path applies", () => {
|
|
119
|
+
expect(isArchiveBySenderAuthorized({ trustClass: "guardian" })).toBe(false);
|
|
120
|
+
expect(
|
|
121
|
+
isArchiveBySenderAuthorized({
|
|
122
|
+
trustClass: "trusted_contact",
|
|
123
|
+
triggeredBySurfaceAction: false,
|
|
124
|
+
userApproved: false,
|
|
125
|
+
}),
|
|
126
|
+
).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective capabilities — trust-class capabilities COMPOSED with runtime context.
|
|
3
|
+
*
|
|
4
|
+
* `resolveCapabilities` (`capabilities.ts`) answers "what may this trust class
|
|
5
|
+
* do" from the actor's class alone, and stays deliberately pure (no context
|
|
6
|
+
* dependencies) so it remains the single fail-closed trust boundary. Some real
|
|
7
|
+
* decisions additionally depend on runtime context — the channel a request
|
|
8
|
+
* arrived on, surface actions, task authorization, a feature flag. Those
|
|
9
|
+
* compositions belong here, in named/testable helpers, rather than re-derived
|
|
10
|
+
* inline at each call site (which scatters a single policy across the codebase).
|
|
11
|
+
*
|
|
12
|
+
* Scope: this module composes capabilities with *actor/request* context. It does
|
|
13
|
+
* not read global config or feature flags itself — callers resolve those and
|
|
14
|
+
* pass the result in (e.g. `lockdownEnabled`), keeping this layer focused on the
|
|
15
|
+
* capability composition. `resolveRoutingState` in `trust-context-resolver.ts`
|
|
16
|
+
* is the same shape (capability + guardian-route context → `promptWaitingAllowed`)
|
|
17
|
+
* and predates this module; it stays where it is.
|
|
18
|
+
*/
|
|
19
|
+
import type { TrustClass } from "./actor-trust-resolver.js";
|
|
20
|
+
import { resolveCapabilities } from "./capabilities.js";
|
|
21
|
+
|
|
22
|
+
type RawTrustClass = TrustClass | (string & {}) | undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Channels that are themselves privileged document surfaces. Actors on these get
|
|
26
|
+
* privileged document access regardless of trust class — the `vellum` first-party
|
|
27
|
+
* console is the operator's own surface, not an external contact channel.
|
|
28
|
+
*/
|
|
29
|
+
const PRIVILEGED_DOCUMENT_CHANNELS = new Set<string>(["vellum"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether an actor may perform privileged (non-conversation-scoped) document
|
|
33
|
+
* operations. True when the trust class grants it OR the request arrived on a
|
|
34
|
+
* privileged channel.
|
|
35
|
+
*/
|
|
36
|
+
export function canActOnPrivilegedDocuments(actor: {
|
|
37
|
+
trustClass: RawTrustClass;
|
|
38
|
+
executionChannel?: string;
|
|
39
|
+
}): boolean {
|
|
40
|
+
return (
|
|
41
|
+
resolveCapabilities(actor.trustClass).canAccessPrivilegedDocuments ||
|
|
42
|
+
(actor.executionChannel != null &&
|
|
43
|
+
PRIVILEGED_DOCUMENT_CHANNELS.has(actor.executionChannel))
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the CES shell lockdown applies to this actor. Active when the lockdown
|
|
49
|
+
* flag is enabled AND the actor's trust class cannot run an unsandboxed shell.
|
|
50
|
+
* Callers resolve the flag (`isCesShellLockdownEnabled(config)`) and pass it in.
|
|
51
|
+
*/
|
|
52
|
+
export function isUntrustedShellLockdownActive(actor: {
|
|
53
|
+
trustClass: RawTrustClass;
|
|
54
|
+
lockdownEnabled: boolean;
|
|
55
|
+
}): boolean {
|
|
56
|
+
return (
|
|
57
|
+
actor.lockdownEnabled &&
|
|
58
|
+
!resolveCapabilities(actor.trustClass).canRunUnsandboxedShell
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether an archive-by-sender invocation is authorized. Any one of a surface
|
|
64
|
+
* action, a task-batch authorization, or an explicit prompt approval suffices.
|
|
65
|
+
* Absent those, the actor's own `user_approved` flag only counts when its trust
|
|
66
|
+
* class may self-authorize archive-by-sender.
|
|
67
|
+
*/
|
|
68
|
+
export function isArchiveBySenderAuthorized(args: {
|
|
69
|
+
trustClass: RawTrustClass;
|
|
70
|
+
triggeredBySurfaceAction?: boolean;
|
|
71
|
+
batchAuthorizedByTask?: boolean;
|
|
72
|
+
approvedViaPrompt?: boolean;
|
|
73
|
+
userApproved?: boolean;
|
|
74
|
+
}): boolean {
|
|
75
|
+
const selfAuthorized =
|
|
76
|
+
args.userApproved === true &&
|
|
77
|
+
resolveCapabilities(args.trustClass).canSelfAuthorizeArchiveBySender;
|
|
78
|
+
return (
|
|
79
|
+
args.triggeredBySurfaceAction === true ||
|
|
80
|
+
args.batchAuthorizedByTask === true ||
|
|
81
|
+
args.approvedViaPrompt === true ||
|
|
82
|
+
selfAuthorized
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type FeedItemStatus,
|
|
29
29
|
HomeFeedResponseSchema,
|
|
30
30
|
} from "../../api/responses/home.js";
|
|
31
|
+
import { enrichFeedItemsWithSource } from "../../home/feed-source-enrichment.js";
|
|
31
32
|
import { patchFeedItemStatus, readHomeFeed } from "../../home/feed-writer.js";
|
|
32
33
|
import { revalidateHomeContentInBackground } from "../../home/home-content-refresh.js";
|
|
33
34
|
import { getPersonalizedGreeting } from "../../home/home-greeting.js";
|
|
@@ -135,8 +136,10 @@ export async function handleGetHomeFeed({
|
|
|
135
136
|
// v2 schema dropped per-item `minTimeAway` gating; surface every item
|
|
136
137
|
// and let the client decide what to render based on its own
|
|
137
138
|
// session state. `timeAwaySeconds` survives only to feed the
|
|
138
|
-
// context-banner relative-time label.
|
|
139
|
-
|
|
139
|
+
// context-banner relative-time label. Each item is enriched with its
|
|
140
|
+
// source-conversation classification (`sourceType`/`sourceKey`/
|
|
141
|
+
// `sourceLabel`) so clients can filter the feed by what produced it.
|
|
142
|
+
const filtered = enrichFeedItemsWithSource(feed.items);
|
|
140
143
|
|
|
141
144
|
const now = new Date();
|
|
142
145
|
|
|
@@ -287,7 +290,9 @@ export function handleListHomeFeed({
|
|
|
287
290
|
const total = filtered.length;
|
|
288
291
|
const offset = params.offset ?? 0;
|
|
289
292
|
const limit = params.limit ?? 20;
|
|
290
|
-
const items =
|
|
293
|
+
const items = enrichFeedItemsWithSource(
|
|
294
|
+
filtered.slice(offset, offset + limit),
|
|
295
|
+
);
|
|
291
296
|
|
|
292
297
|
return {
|
|
293
298
|
items,
|
|
@@ -13,14 +13,11 @@ import {
|
|
|
13
13
|
searchDocumentsByTitle,
|
|
14
14
|
updateDocumentContent,
|
|
15
15
|
} from "../../documents/document-store.js";
|
|
16
|
-
import {
|
|
16
|
+
import { canActOnPrivilegedDocuments } from "../../runtime/effective-capabilities.js";
|
|
17
17
|
import type { ToolContext, ToolExecutionResult } from "../types.js";
|
|
18
18
|
|
|
19
19
|
function isPrivilegedDocumentActor(context: ToolContext): boolean {
|
|
20
|
-
return (
|
|
21
|
-
resolveCapabilities(context.trustClass).canAccessPrivilegedDocuments ||
|
|
22
|
-
context.executionChannel === "vellum"
|
|
23
|
-
);
|
|
20
|
+
return canActOnPrivilegedDocuments(context);
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
export function documentNotFound(surfaceId: string): ToolExecutionResult {
|
package/src/tools/executor.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { bridgeCesApproval } from "../credential-execution/approval-bridge.js";
|
|
|
5
5
|
import { isCesShellLockdownEnabled } from "../credential-execution/feature-gates.js";
|
|
6
6
|
import { PermissionPrompter } from "../permissions/prompter.js";
|
|
7
7
|
import { RiskLevel } from "../permissions/types.js";
|
|
8
|
-
import {
|
|
8
|
+
import { isUntrustedShellLockdownActive } from "../runtime/effective-capabilities.js";
|
|
9
9
|
import { redactSensitiveFields } from "../security/redaction.js";
|
|
10
10
|
import { getCesClient } from "../security/secure-keys.js";
|
|
11
11
|
import { TokenExpiredError } from "../security/token-manager.js";
|
|
@@ -128,8 +128,10 @@ export class ToolExecutor {
|
|
|
128
128
|
// skip this assignment entirely - defeating the lockdown.
|
|
129
129
|
if (
|
|
130
130
|
name === "host_bash" &&
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
isUntrustedShellLockdownActive({
|
|
132
|
+
trustClass: context.trustClass,
|
|
133
|
+
lockdownEnabled: isCesShellLockdownEnabled(getConfig()),
|
|
134
|
+
})
|
|
133
135
|
) {
|
|
134
136
|
context.forcePromptSideEffects = true;
|
|
135
137
|
}
|
|
@@ -25,7 +25,7 @@ import { HostBashProxy } from "../../daemon/host-bash-proxy.js";
|
|
|
25
25
|
import { RiskLevel } from "../../permissions/types.js";
|
|
26
26
|
import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
|
|
27
27
|
import { assistantEventHub } from "../../runtime/assistant-event-hub.js";
|
|
28
|
-
import {
|
|
28
|
+
import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabilities.js";
|
|
29
29
|
import { redactSecrets } from "../../security/secret-scanner.js";
|
|
30
30
|
import { getLogger } from "../../util/logger.js";
|
|
31
31
|
import {
|
|
@@ -204,9 +204,10 @@ export const hostShellTool = {
|
|
|
204
204
|
// NOTE: forcePromptSideEffects is set in executor.ts BEFORE the
|
|
205
205
|
// permission check runs, not here. Setting it here would be too late
|
|
206
206
|
// because execute() is called after permissions have already been evaluated.
|
|
207
|
-
const hostLockdownActive =
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
const hostLockdownActive = isUntrustedShellLockdownActive({
|
|
208
|
+
trustClass: context.trustClass,
|
|
209
|
+
lockdownEnabled: isCesShellLockdownEnabled(config),
|
|
210
|
+
});
|
|
210
211
|
|
|
211
212
|
// Guard: non-host-proxy interfaces need an explicit target when multiple
|
|
212
213
|
// capable clients are connected to avoid ambiguous untargeted broadcasts.
|
|
@@ -5,7 +5,7 @@ import { getConfig } from "../../config/loader.js";
|
|
|
5
5
|
import { isCesShellLockdownEnabled } from "../../credential-execution/feature-gates.js";
|
|
6
6
|
import { RiskLevel } from "../../permissions/types.js";
|
|
7
7
|
import { wakeAgentForOpportunity } from "../../runtime/agent-wake.js";
|
|
8
|
-
import {
|
|
8
|
+
import { isUntrustedShellLockdownActive } from "../../runtime/effective-capabilities.js";
|
|
9
9
|
import { redactSecrets } from "../../security/secret-scanner.js";
|
|
10
10
|
import { getLogger } from "../../util/logger.js";
|
|
11
11
|
import { getDataDir } from "../../util/platform.js";
|
|
@@ -117,9 +117,10 @@ export const shellTool = {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const config = getConfig();
|
|
120
|
-
const shellLockdownActive =
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
const shellLockdownActive = isUntrustedShellLockdownActive({
|
|
121
|
+
trustClass: context.trustClass,
|
|
122
|
+
lockdownEnabled: isCesShellLockdownEnabled(config),
|
|
123
|
+
});
|
|
123
124
|
|
|
124
125
|
const networkMode: "off" | "proxied" =
|
|
125
126
|
input.network_mode === "proxied" ? "proxied" : "off";
|
package/src/util/disk-usage.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, statfsSync } from "node:fs";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getIsContainerized,
|
|
6
|
+
getIsPlatform,
|
|
7
|
+
getMinikubeStorageSize,
|
|
8
|
+
} from "../config/env-registry.js";
|
|
5
9
|
import { getWorkspaceDir } from "./platform.js";
|
|
6
10
|
|
|
7
11
|
export interface DiskUsageInfo {
|
|
@@ -58,6 +62,57 @@ export function __resetDiskUsageCacheForTests(): void {
|
|
|
58
62
|
duCachePaths = null;
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* How the workspace volume's capacity should be reported when statfsSync
|
|
67
|
+
* cannot see the volume directly.
|
|
68
|
+
*
|
|
69
|
+
* - `fixed-cap`: minikube hostPath PVC — the PVC request is the hard
|
|
70
|
+
* capacity, so usage is measured with `du` and free space is whatever
|
|
71
|
+
* remains within the quota.
|
|
72
|
+
* - `host-free`: local Docker named volume — the volume is backed by the
|
|
73
|
+
* host (or Colima VM) filesystem and can grow into its free space, so
|
|
74
|
+
* usage is measured with `du` and the effective capacity is current usage
|
|
75
|
+
* plus the host's remaining headroom.
|
|
76
|
+
* - `none`: statfsSync already reports the volume accurately (bare metal, or
|
|
77
|
+
* a platform-managed instance on a CSI-backed PVC), so use it directly.
|
|
78
|
+
*/
|
|
79
|
+
type WorkspaceVolumeReporting =
|
|
80
|
+
| { kind: "none" }
|
|
81
|
+
| { kind: "fixed-cap"; totalBytes: number }
|
|
82
|
+
| { kind: "host-free" };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decide how to report the workspace volume's capacity. Both minikube
|
|
86
|
+
* hostPath PVCs and local Docker named volumes are backed by the host
|
|
87
|
+
* filesystem, so statfsSync reports the host's entire disk rather than the
|
|
88
|
+
* workspace volume — in both cases we measure actual usage with `du` instead.
|
|
89
|
+
*/
|
|
90
|
+
function classifyWorkspaceVolume(
|
|
91
|
+
fsTotalBytes: number,
|
|
92
|
+
): WorkspaceVolumeReporting {
|
|
93
|
+
// Minikube mode: the platform passes the PVC storage size so we can report
|
|
94
|
+
// accurate capacity. Detect the hostPath case by comparing filesystem size
|
|
95
|
+
// against the PVC size — if the filesystem is larger, statfsSync is seeing
|
|
96
|
+
// the host disk and we should measure the directory instead.
|
|
97
|
+
const storageSizeRaw = getMinikubeStorageSize();
|
|
98
|
+
if (storageSizeRaw) {
|
|
99
|
+
const pvcTotalBytes = parseK8sMemoryBytes(storageSizeRaw);
|
|
100
|
+
if (pvcTotalBytes !== null && fsTotalBytes > pvcTotalBytes * 1.1) {
|
|
101
|
+
return { kind: "fixed-cap", totalBytes: pvcTotalBytes };
|
|
102
|
+
}
|
|
103
|
+
return { kind: "none" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Local Docker hatch: the workspace is a Docker named volume backed by the
|
|
107
|
+
// host filesystem. Platform-managed remote instances run on CSI-backed PVCs
|
|
108
|
+
// where statfsSync already reports the volume, so they are excluded.
|
|
109
|
+
if (getIsContainerized() && !getIsPlatform()) {
|
|
110
|
+
return { kind: "host-free" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { kind: "none" };
|
|
114
|
+
}
|
|
115
|
+
|
|
61
116
|
export function getDiskUsageInfo(): DiskUsageInfo | null {
|
|
62
117
|
try {
|
|
63
118
|
const wsDir = getWorkspaceDir();
|
|
@@ -68,28 +123,28 @@ export function getDiskUsageInfo(): DiskUsageInfo | null {
|
|
|
68
123
|
const bytesToMb = (b: number) =>
|
|
69
124
|
Math.round((b / (1024 * 1024)) * 100) / 100;
|
|
70
125
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
126
|
+
const reporting = classifyWorkspaceVolume(fsTotalBytes);
|
|
127
|
+
if (reporting.kind !== "none") {
|
|
128
|
+
const volumePaths = [diskPath];
|
|
129
|
+
if (diskPath !== "/data" && existsSync("/data")) {
|
|
130
|
+
volumePaths.push("/data");
|
|
131
|
+
}
|
|
132
|
+
const usedBytes = getCachedDirectorySizeBytes(volumePaths);
|
|
133
|
+
if (usedBytes !== null) {
|
|
134
|
+
const totalBytes =
|
|
135
|
+
reporting.kind === "fixed-cap"
|
|
136
|
+
? reporting.totalBytes
|
|
137
|
+
: usedBytes + fsFreeBytes;
|
|
138
|
+
const freeBytes =
|
|
139
|
+
reporting.kind === "fixed-cap"
|
|
140
|
+
? Math.max(0, reporting.totalBytes - usedBytes)
|
|
141
|
+
: fsFreeBytes;
|
|
142
|
+
return {
|
|
143
|
+
path: diskPath,
|
|
144
|
+
totalMb: bytesToMb(totalBytes),
|
|
145
|
+
usedMb: bytesToMb(usedBytes),
|
|
146
|
+
freeMb: bytesToMb(freeBytes),
|
|
147
|
+
};
|
|
93
148
|
}
|
|
94
149
|
}
|
|
95
150
|
|