mercury-agent 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/goals/rehearsal-bench/decisions.md +41 -3
- package/docs/goals/rehearsal-bench/roadmap.md +3 -1
- package/docs/goals/release-gate/decisions.md +40 -0
- package/docs/goals/release-gate/roadmap.md +35 -2
- package/examples/extensions/gws/index.ts +99 -8
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +27 -6
- package/examples/extensions/morning/lib/hosts.ts +16 -0
- package/examples/extensions/morning/lib/morning.ts +78 -0
- package/examples/extensions/morning/lib/upload.ts +584 -0
- package/examples/extensions/morning/skill/SKILL.md +34 -4
- package/package.json +7 -3
- package/src/core/runtime.ts +164 -2
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expense file upload, host-side.
|
|
3
|
+
*
|
|
4
|
+
* Morning's upload is two hops and both live here, in one broker action:
|
|
5
|
+
*
|
|
6
|
+
* 1. `GET {uploadBase}/file-upload/v1/url` → a pre-signed S3 POST.
|
|
7
|
+
* 2. `POST <that url>` → multipart/form-data, no Authorization header.
|
|
8
|
+
*
|
|
9
|
+
* They are not two verbs, and that is deliberate: the pre-signed POST is valid
|
|
10
|
+
* for 60 SECONDS. A model that had to decide between two verbs would spend the
|
|
11
|
+
* window deciding. Nothing between the two hops may block on anything slow.
|
|
12
|
+
*
|
|
13
|
+
* Uploading always creates an expense DRAFT, which Morning parses itself and a
|
|
14
|
+
* human approves in Morning's UI. That is why this needs no
|
|
15
|
+
* `accountingClassification` — the field that keeps `POST /expenses` out of the
|
|
16
|
+
* extension — and why the human gate here is real rather than prompted.
|
|
17
|
+
*
|
|
18
|
+
* The container never sends bytes. It names a path RELATIVE to its own space
|
|
19
|
+
* workspace and the host reads it. `resolveInSpace` below is the only thing
|
|
20
|
+
* standing between that and an arbitrary-file-read primitive handed to a
|
|
21
|
+
* prompt-injectable agent, so read its comment before changing it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
extToMime,
|
|
28
|
+
hasExecutableHeader,
|
|
29
|
+
isAllowedExtension,
|
|
30
|
+
} from "mercury-agent/core/media";
|
|
31
|
+
import { hostsFor, type MorningEnvironment } from "./hosts.js";
|
|
32
|
+
|
|
33
|
+
/** Same bound as the rest of the extension; nothing upstream sets one. */
|
|
34
|
+
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
35
|
+
|
|
36
|
+
/** Morning's own constant for this flow — `source` must be 5. */
|
|
37
|
+
const UPLOAD_SOURCE = 5;
|
|
38
|
+
|
|
39
|
+
export interface CapabilityResult {
|
|
40
|
+
status?: number;
|
|
41
|
+
data: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UploadContext {
|
|
45
|
+
env: MorningEnvironment;
|
|
46
|
+
spaceId: string;
|
|
47
|
+
/**
|
|
48
|
+
* Root of all space workspaces, already project-resolved by the caller.
|
|
49
|
+
* `<spacesDir>/<spaceId>/` is the confinement boundary.
|
|
50
|
+
*/
|
|
51
|
+
spacesDir: string;
|
|
52
|
+
fetchImpl?: typeof fetch;
|
|
53
|
+
/** Host-side logger. Refusals are logged here, never returned in detail. */
|
|
54
|
+
logDenial?: (message: string, detail: Record<string, unknown>) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Token access, supplied by the caller so this module does not reach for the
|
|
59
|
+
* database. `getToken(true)` must bypass any cache — it is what makes the one
|
|
60
|
+
* 401 retry below meaningful.
|
|
61
|
+
*/
|
|
62
|
+
export interface TokenProvider {
|
|
63
|
+
getToken(
|
|
64
|
+
forceRefresh: boolean,
|
|
65
|
+
): Promise<
|
|
66
|
+
{ ok: true; token: string } | { ok: false; result: CapabilityResult }
|
|
67
|
+
>;
|
|
68
|
+
/** Called when Morning rejects a freshly minted token, so statusCheck can see it. */
|
|
69
|
+
recordAuthError(message: string): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The three outcomes of resolving a caller-supplied path.
|
|
74
|
+
*
|
|
75
|
+
* `refused` and `missing` are deliberately distinct, and the line between them
|
|
76
|
+
* is what keeps this from being a filesystem oracle: `missing` is returned ONLY
|
|
77
|
+
* for a path that is structurally sound and lexically inside the caller's own
|
|
78
|
+
* space. Confirming such a path is empty tells the caller nothing it did not
|
|
79
|
+
* already know — it named the path. Everything else, including any path that
|
|
80
|
+
* resolves outside the space, is a single indistinguishable `refused`.
|
|
81
|
+
*/
|
|
82
|
+
export type ResolveOutcome =
|
|
83
|
+
| { ok: true; absPath: string }
|
|
84
|
+
| { ok: false; reason: "refused" | "missing" };
|
|
85
|
+
|
|
86
|
+
const REFUSED = { ok: false, reason: "refused" } as const;
|
|
87
|
+
const MISSING = { ok: false, reason: "missing" } as const;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a container-supplied relative path inside one space's workspace.
|
|
91
|
+
*
|
|
92
|
+
* Three properties, each of which has been a real bug somewhere:
|
|
93
|
+
*
|
|
94
|
+
* - **Absence denies.** An empty, missing or non-string path is refused before
|
|
95
|
+
* anything touches the filesystem. A guard whose absent case falls through
|
|
96
|
+
* to allow is not a guard (see
|
|
97
|
+
* `docs/debug/moderate/08-2026/2026-08-27-body-size-limit-skipped-without-content-length.md`).
|
|
98
|
+
* - **Symlinks are resolved BEFORE the comparison, not after.** `inbox/` is
|
|
99
|
+
* written by a lower-trust boundary, so a symlink there pointing at the
|
|
100
|
+
* host's `.env` is precisely the attack. Comparing the pre-resolution path
|
|
101
|
+
* would pass it.
|
|
102
|
+
* - **The comparison is path-segment-wise, never a string prefix.** With a bare
|
|
103
|
+
* `startsWith`, space `a` reaches everything in space `abc`.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveInSpace(
|
|
106
|
+
spacesDir: string,
|
|
107
|
+
spaceId: string,
|
|
108
|
+
rawPath: unknown,
|
|
109
|
+
): Promise<ResolveOutcome> {
|
|
110
|
+
if (typeof rawPath !== "string") return REFUSED;
|
|
111
|
+
const candidate = rawPath.trim();
|
|
112
|
+
if (!candidate) return REFUSED;
|
|
113
|
+
|
|
114
|
+
// Cheap structural rejections first, before any I/O. Not the real guard —
|
|
115
|
+
// the realpath comparison below is — but they keep obvious junk out of the
|
|
116
|
+
// filesystem calls and make the intent readable.
|
|
117
|
+
if (path.isAbsolute(candidate)) return REFUSED;
|
|
118
|
+
if (candidate.split(/[\\/]/).includes("..")) return REFUSED;
|
|
119
|
+
|
|
120
|
+
// Resolve the base's own symlinks first (a spacesDir under /var on macOS is
|
|
121
|
+
// one), then build the target from the resolved base so the two are
|
|
122
|
+
// comparable.
|
|
123
|
+
let realBase: string;
|
|
124
|
+
try {
|
|
125
|
+
realBase = await realpath(path.resolve(spacesDir, spaceId));
|
|
126
|
+
} catch {
|
|
127
|
+
return REFUSED;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const target = path.resolve(realBase, candidate);
|
|
131
|
+
// Lexical containment before touching the target: a path that is outside
|
|
132
|
+
// even on paper is refused without a filesystem probe.
|
|
133
|
+
if (!isInside(realBase, target)) return REFUSED;
|
|
134
|
+
|
|
135
|
+
let realTarget: string;
|
|
136
|
+
try {
|
|
137
|
+
realTarget = await realpath(target);
|
|
138
|
+
} catch {
|
|
139
|
+
return (await isPlainTypo(realBase, target)) ? MISSING : REFUSED;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The decisive check, AFTER symlink resolution — this is the one that catches
|
|
143
|
+
// a link in `inbox/` pointing anywhere else on the host.
|
|
144
|
+
if (!isInside(realBase, realTarget)) return REFUSED;
|
|
145
|
+
return { ok: true, absPath: realTarget };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `realpath` failed on a path that is lexically inside the space. Is that a
|
|
150
|
+
* plain typo, or something planted?
|
|
151
|
+
*
|
|
152
|
+
* Only a typo may be reported as `missing`, because the 404/400 split is
|
|
153
|
+
* otherwise a one-bit existence oracle for arbitrary host paths: a container
|
|
154
|
+
* can write symlinks into its own workspace, so it could plant a link to any
|
|
155
|
+
* path, read the status code, and learn whether the target exists. No content
|
|
156
|
+
* escapes either way — the containment check still holds for anything that
|
|
157
|
+
* resolves — but an oracle is exactly what the "never echo the resolved path"
|
|
158
|
+
* rule exists to prevent, so the two conditions below close it.
|
|
159
|
+
*
|
|
160
|
+
* 1. Nothing may exist at the name at all. `lstat` sees a dangling symlink
|
|
161
|
+
* where `realpath` does not, and a dangling symlink is never a typo.
|
|
162
|
+
* 2. The parent directory must itself resolve inside the space, or a symlinked
|
|
163
|
+
* intermediate directory is doing the escaping.
|
|
164
|
+
*/
|
|
165
|
+
async function isPlainTypo(realBase: string, target: string): Promise<boolean> {
|
|
166
|
+
try {
|
|
167
|
+
await lstat(target);
|
|
168
|
+
return false; // Something is there; realpath refused to follow it.
|
|
169
|
+
} catch {
|
|
170
|
+
// Genuinely nothing at that name — carry on to the parent check.
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
return isInside(realBase, await realpath(path.dirname(target)));
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is `target` the same as, or beneath, `base`?
|
|
181
|
+
*
|
|
182
|
+
* `path.relative` rather than a string compare: it normalises separators and
|
|
183
|
+
* yields a result starting with `..` exactly when the target sits outside, so
|
|
184
|
+
* `/spaces/abc` is correctly outside `/spaces/a`. An empty result means the two
|
|
185
|
+
* are the same path, which is not a file and so not acceptable either — the
|
|
186
|
+
* caller's `stat` rejects it, but returning true here keeps this function about
|
|
187
|
+
* containment alone.
|
|
188
|
+
*/
|
|
189
|
+
function isInside(base: string, target: string): boolean {
|
|
190
|
+
const rel = path.relative(base, target);
|
|
191
|
+
if (rel === "") return true;
|
|
192
|
+
if (rel.startsWith("..")) return false;
|
|
193
|
+
return !path.isAbsolute(rel);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface LoadedFile {
|
|
197
|
+
bytes: Buffer;
|
|
198
|
+
name: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read and validate the file once.
|
|
203
|
+
*
|
|
204
|
+
* The buffer is read a single time and reused for the header check, the size
|
|
205
|
+
* check and the upload body, so the bytes that were validated are exactly the
|
|
206
|
+
* bytes that get sent. Re-reading before the upload would leave a window for a
|
|
207
|
+
* swap by whatever wrote the file.
|
|
208
|
+
*/
|
|
209
|
+
async function loadFile(
|
|
210
|
+
absPath: string,
|
|
211
|
+
displayName: string,
|
|
212
|
+
): Promise<LoadedFile | CapabilityResult> {
|
|
213
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
214
|
+
try {
|
|
215
|
+
info = await stat(absPath);
|
|
216
|
+
} catch {
|
|
217
|
+
return { status: 404, data: { error: `No such file: ${displayName}` } };
|
|
218
|
+
}
|
|
219
|
+
if (!info.isFile()) {
|
|
220
|
+
return { status: 400, data: { error: `Not a file: ${displayName}` } };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const name = path.basename(absPath);
|
|
224
|
+
if (!isAllowedExtension(name)) {
|
|
225
|
+
return {
|
|
226
|
+
status: 415,
|
|
227
|
+
data: {
|
|
228
|
+
error: `Refusing to upload ${displayName}: the file type is not one Mercury handles.`,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let bytes: Buffer;
|
|
234
|
+
try {
|
|
235
|
+
bytes = await readFile(absPath);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
return {
|
|
239
|
+
status: 404,
|
|
240
|
+
data: { error: `Could not read ${displayName}: ${message}` },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (hasExecutableHeader(bytes)) {
|
|
245
|
+
return {
|
|
246
|
+
status: 415,
|
|
247
|
+
data: {
|
|
248
|
+
error: `Refusing to upload ${displayName}: its first bytes are an executable header, whatever the extension says.`,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { bytes, name };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function isResult(v: unknown): v is CapabilityResult {
|
|
257
|
+
return typeof v === "object" && v !== null && "data" in v;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface PresignedPost {
|
|
261
|
+
url: string;
|
|
262
|
+
fields: Record<string, string>;
|
|
263
|
+
maxFileSize: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Outcome of step 1. The upstream status is kept separate from the error the
|
|
268
|
+
* caller would return, because the retry decision turns on the status alone and
|
|
269
|
+
* collapsing everything into a 502 would hide the one case worth retrying.
|
|
270
|
+
*/
|
|
271
|
+
type PresignOutcome =
|
|
272
|
+
| { ok: true; presigned: PresignedPost }
|
|
273
|
+
| { ok: false; httpStatus: number | null; result: CapabilityResult };
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Step 1 — ask Morning for a pre-signed POST.
|
|
277
|
+
*
|
|
278
|
+
* Not `callOnce`: a different base, query parameters rather than a JSON body,
|
|
279
|
+
* and success is 201 rather than 200.
|
|
280
|
+
*/
|
|
281
|
+
async function requestUploadUrl(
|
|
282
|
+
ctx: UploadContext,
|
|
283
|
+
token: string,
|
|
284
|
+
expenseId: string | null,
|
|
285
|
+
): Promise<PresignOutcome> {
|
|
286
|
+
const { uploadBase } = hostsFor(ctx.env);
|
|
287
|
+
const data = expenseId
|
|
288
|
+
? { id: expenseId, source: UPLOAD_SOURCE, state: "expense" }
|
|
289
|
+
: { source: UPLOAD_SOURCE };
|
|
290
|
+
const query = new URLSearchParams({
|
|
291
|
+
context: "expense",
|
|
292
|
+
data: JSON.stringify(data),
|
|
293
|
+
});
|
|
294
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
295
|
+
|
|
296
|
+
let res: Response;
|
|
297
|
+
try {
|
|
298
|
+
res = await fetchImpl(`${uploadBase}/file-upload/v1/url?${query}`, {
|
|
299
|
+
method: "GET",
|
|
300
|
+
headers: {
|
|
301
|
+
"Content-Type": "application/json",
|
|
302
|
+
Authorization: `Bearer ${token}`,
|
|
303
|
+
},
|
|
304
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
httpStatus: null,
|
|
311
|
+
result: {
|
|
312
|
+
status: 502,
|
|
313
|
+
data: {
|
|
314
|
+
error: `Could not reach Morning (${ctx.env}): ${message}`,
|
|
315
|
+
transport: true,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const raw = await res.text().catch(() => "");
|
|
322
|
+
let body: unknown = raw;
|
|
323
|
+
try {
|
|
324
|
+
body = raw ? JSON.parse(raw) : null;
|
|
325
|
+
} catch {
|
|
326
|
+
// Not JSON — keep the raw text for the error below.
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 201 is the documented success. Treat any other 2xx as success too rather
|
|
330
|
+
// than pinning the exact code, but anything else is Morning saying no.
|
|
331
|
+
if (!res.ok) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
httpStatus: res.status,
|
|
335
|
+
result: {
|
|
336
|
+
status: res.status === 429 ? 429 : 502,
|
|
337
|
+
data: {
|
|
338
|
+
error: "Morning refused to issue an upload URL.",
|
|
339
|
+
httpStatus: res.status,
|
|
340
|
+
morning: body,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const parsed = body as Partial<PresignedPost> | null;
|
|
347
|
+
if (
|
|
348
|
+
!parsed ||
|
|
349
|
+
typeof parsed.url !== "string" ||
|
|
350
|
+
typeof parsed.fields !== "object" ||
|
|
351
|
+
parsed.fields === null
|
|
352
|
+
) {
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
httpStatus: res.status,
|
|
356
|
+
result: {
|
|
357
|
+
status: 502,
|
|
358
|
+
data: {
|
|
359
|
+
error:
|
|
360
|
+
"Morning returned an upload URL in a shape this extension does not understand.",
|
|
361
|
+
// Key names only, never the body. A malformed-but-2xx response can
|
|
362
|
+
// still carry `url` and `fields`, and those are a bearer capability
|
|
363
|
+
// for the bucket that must not reach the container.
|
|
364
|
+
keys: Object.keys((body ?? {}) as Record<string, unknown>),
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
presigned: {
|
|
373
|
+
url: parsed.url,
|
|
374
|
+
fields: parsed.fields as Record<string, string>,
|
|
375
|
+
// Absent or nonsensical → 0, which the caller reads as "no stated cap".
|
|
376
|
+
maxFileSize:
|
|
377
|
+
typeof parsed.maxFileSize === "number" && parsed.maxFileSize > 0
|
|
378
|
+
? parsed.maxFileSize
|
|
379
|
+
: 0,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Step 2 — the pre-signed POST itself.
|
|
386
|
+
*
|
|
387
|
+
* Every field Morning returned goes in first, in the order returned, and `file`
|
|
388
|
+
* goes LAST: an S3 pre-signed POST ignores anything after the file part, so a
|
|
389
|
+
* field appended afterwards is silently dropped and the signature fails.
|
|
390
|
+
*
|
|
391
|
+
* No Authorization header. The signature is in the fields; sending a bearer
|
|
392
|
+
* token to a bucket would leak it to a third party for no benefit.
|
|
393
|
+
*
|
|
394
|
+
* NOT retried. A POST that failed midway may already have created a draft, and
|
|
395
|
+
* a blind retry risks a duplicate expense against real books. Report instead.
|
|
396
|
+
*/
|
|
397
|
+
async function putToBucket(
|
|
398
|
+
ctx: UploadContext,
|
|
399
|
+
presigned: PresignedPost,
|
|
400
|
+
file: LoadedFile,
|
|
401
|
+
): Promise<CapabilityResult | null> {
|
|
402
|
+
const form = new FormData();
|
|
403
|
+
for (const [key, value] of Object.entries(presigned.fields)) {
|
|
404
|
+
form.append(key, String(value));
|
|
405
|
+
}
|
|
406
|
+
// Give the part its real content type. Morning's `fields` carry no
|
|
407
|
+
// Content-Type condition, so an untyped part would not break the signature —
|
|
408
|
+
// but it would reach the parser as application/octet-stream, and being parsed
|
|
409
|
+
// is the entire point of an expense draft.
|
|
410
|
+
form.append(
|
|
411
|
+
"file",
|
|
412
|
+
new Blob([new Uint8Array(file.bytes)], { type: extToMime(file.name) }),
|
|
413
|
+
file.name,
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
417
|
+
let res: Response;
|
|
418
|
+
try {
|
|
419
|
+
res = await fetchImpl(presigned.url, {
|
|
420
|
+
method: "POST",
|
|
421
|
+
body: form,
|
|
422
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
426
|
+
return {
|
|
427
|
+
status: 502,
|
|
428
|
+
data: {
|
|
429
|
+
error: `The upload did not complete: ${message}`,
|
|
430
|
+
transport: true,
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (res.status === 403) {
|
|
436
|
+
// The overwhelmingly likely cause, and the one the user can act on.
|
|
437
|
+
return {
|
|
438
|
+
status: 502,
|
|
439
|
+
data: {
|
|
440
|
+
error:
|
|
441
|
+
"The upload link expired before the file finished uploading (it is valid for 60 seconds). Send the file again.",
|
|
442
|
+
expired: true,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// S3 is not described by Morning's OpenAPI document, so the exact success
|
|
448
|
+
// code is not spec-verified. A pre-signed POST with no `success_action_status`
|
|
449
|
+
// answers 204; accept any 2xx rather than pinning it.
|
|
450
|
+
if (!res.ok) {
|
|
451
|
+
// Only S3's <Code> element, never the raw body. An error document can echo
|
|
452
|
+
// request material — policy content among it — and the pre-signed fields are
|
|
453
|
+
// a bearer capability for the bucket that must not reach the container.
|
|
454
|
+
const body = await res.text().catch(() => "");
|
|
455
|
+
const code = body.match(/<Code>([^<]{1,64})<\/Code>/)?.[1];
|
|
456
|
+
return {
|
|
457
|
+
status: 502,
|
|
458
|
+
data: {
|
|
459
|
+
error: "The storage service rejected the upload.",
|
|
460
|
+
httpStatus: res.status,
|
|
461
|
+
...(code ? { code } : {}),
|
|
462
|
+
transport: true,
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The `expense-upload` verb.
|
|
472
|
+
*
|
|
473
|
+
* Tokens come from the caller, but the one-401-retry policy lives here, because
|
|
474
|
+
* only this module knows which of the two hops is safe to repeat. Step 1 is
|
|
475
|
+
* idempotent — it just asks for a URL — so a stale cached token costs one
|
|
476
|
+
* re-mint. Step 2 is never retried at all: a POST that failed midway may
|
|
477
|
+
* already have created a draft.
|
|
478
|
+
*/
|
|
479
|
+
export async function expenseUpload(
|
|
480
|
+
ctx: UploadContext,
|
|
481
|
+
body: Record<string, unknown>,
|
|
482
|
+
tokens: TokenProvider,
|
|
483
|
+
): Promise<CapabilityResult> {
|
|
484
|
+
const rawPath = body.path;
|
|
485
|
+
const displayName = typeof rawPath === "string" ? rawPath : "(no path)";
|
|
486
|
+
|
|
487
|
+
const resolved = await resolveInSpace(ctx.spacesDir, ctx.spaceId, rawPath);
|
|
488
|
+
if (!resolved.ok) {
|
|
489
|
+
// WARN host-side with the detail, generic to the caller. A silent refusal
|
|
490
|
+
// is indistinguishable from "never happened" in the host log, and an
|
|
491
|
+
// informative one is a filesystem oracle for the container.
|
|
492
|
+
ctx.logDenial?.("morning expense-upload: path not usable", {
|
|
493
|
+
spaceId: ctx.spaceId,
|
|
494
|
+
requested: displayName,
|
|
495
|
+
reason: resolved.reason,
|
|
496
|
+
});
|
|
497
|
+
if (resolved.reason === "missing") {
|
|
498
|
+
// A typo should read as a typo. Telling the model the file is simply not
|
|
499
|
+
// there is what makes it list `inbox/` instead of guessing again.
|
|
500
|
+
return {
|
|
501
|
+
status: 404,
|
|
502
|
+
data: { error: `No such file: ${displayName}` },
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
status: 400,
|
|
507
|
+
data: {
|
|
508
|
+
error:
|
|
509
|
+
"expense-upload needs a path to a file inside this space's workspace, such as inbox/receipt.pdf.",
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const loaded = await loadFile(resolved.absPath, displayName);
|
|
515
|
+
if (isResult(loaded)) return loaded;
|
|
516
|
+
|
|
517
|
+
const expenseId =
|
|
518
|
+
typeof body.expenseId === "string" && body.expenseId.trim()
|
|
519
|
+
? body.expenseId.trim()
|
|
520
|
+
: null;
|
|
521
|
+
|
|
522
|
+
// One retry, matching `apiCall`'s policy: the token is either stale (a single
|
|
523
|
+
// re-mint fixes it) or the credentials are rejected (a loop would hammer the
|
|
524
|
+
// endpoint and still fail). The second 401 is recorded so `statusCheck` stops
|
|
525
|
+
// reporting "connected" through a real auth failure.
|
|
526
|
+
let presigned: PresignedPost | undefined;
|
|
527
|
+
for (const attempt of [0, 1]) {
|
|
528
|
+
const token = await tokens.getToken(attempt === 1);
|
|
529
|
+
if (!token.ok) return token.result;
|
|
530
|
+
|
|
531
|
+
const outcome = await requestUploadUrl(ctx, token.token, expenseId);
|
|
532
|
+
if (outcome.ok) {
|
|
533
|
+
presigned = outcome.presigned;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
if (outcome.httpStatus === 401) {
|
|
537
|
+
if (attempt === 0) continue;
|
|
538
|
+
tokens.recordAuthError(
|
|
539
|
+
`Morning returned 401 after a token refresh (${ctx.env}).`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
return outcome.result;
|
|
543
|
+
}
|
|
544
|
+
if (!presigned) {
|
|
545
|
+
// Unreachable: the loop either breaks with a value or returns.
|
|
546
|
+
return {
|
|
547
|
+
status: 502,
|
|
548
|
+
data: { error: "Morning did not issue an upload URL.", transport: true },
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Between the two legs, and only here: the cap is Morning's own number, not a
|
|
553
|
+
// guess, so it cannot be checked before step 1 returns it. Checking it before
|
|
554
|
+
// step 2 means an oversized file fails instantly rather than burning the
|
|
555
|
+
// 60-second window on an upload that was always going to be refused.
|
|
556
|
+
if (
|
|
557
|
+
presigned.maxFileSize > 0 &&
|
|
558
|
+
loaded.bytes.length > presigned.maxFileSize
|
|
559
|
+
) {
|
|
560
|
+
return {
|
|
561
|
+
status: 413,
|
|
562
|
+
data: {
|
|
563
|
+
error: `${loaded.name} is ${loaded.bytes.length} bytes; Morning accepts at most ${presigned.maxFileSize}.`,
|
|
564
|
+
maxFileSize: presigned.maxFileSize,
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const failed = await putToBucket(ctx, presigned, loaded);
|
|
570
|
+
if (failed) return failed;
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
data: {
|
|
574
|
+
uploaded: true,
|
|
575
|
+
mode: expenseId ? "attached" : "draft",
|
|
576
|
+
expenseId,
|
|
577
|
+
file: loaded.name,
|
|
578
|
+
// Said plainly because it is the thing that misleads: Morning has ACCEPTED
|
|
579
|
+
// the file, not finished with it. Parsing is asynchronous and its outcome
|
|
580
|
+
// arrives on a webhook no extension can receive.
|
|
581
|
+
note: "Morning accepted the file. It is parsed asynchronously — use expense-draft-search to see whether the draft appeared.",
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: morning
|
|
3
|
-
description: Issue and look up tax documents in Morning (Green Invoice) — invoices, receipts, quotes —
|
|
3
|
+
description: Issue and look up tax documents in Morning (Green Invoice) — invoices, receipts, quotes — find or create clients, and upload a supplier receipt as an expense draft. Use when someone asks to issue, produce or send an invoice or receipt, asks what was invoiced in a period, asks about a client's documents, or sends a receipt or supplier invoice to file. Fires on חשבונית, חשבונית מס, קבלה, הצעת מחיר, תוציא חשבונית, תפיק חשבונית, לשלוח חשבונית, מורנינג, גרין אינבויס, חשבונית ירוקה, הוצאה, קבלה מספק, תעלה קבלה, תכניס את הקבלה, invoice, receipt, quote, bill a client, expense, upload a receipt, file this receipt. Not for entering an expense by hand or payment collection — those are not supported yet, say so.
|
|
4
4
|
allowed-tools: Bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -52,6 +52,32 @@ behaviour and not an error to work around.
|
|
|
52
52
|
| Issue a document | `document-create` | preview body + `"issueToken":"…"` |
|
|
53
53
|
| Find documents | `document-search` | `mrctl capability morning document-search '{"fromDate":"2026-09-01","toDate":"2026-09-30"}'` |
|
|
54
54
|
| Get download links | `document-links` | `mrctl capability morning document-links '{"id":"<document id>"}'` |
|
|
55
|
+
| Upload a receipt (expense) | `expense-upload` | `mrctl capability morning expense-upload '{"path":"inbox/receipt.pdf"}'` |
|
|
56
|
+
| Check uploaded receipts | `expense-draft-search` | `mrctl capability morning expense-draft-search '{"fromDate":"2026-09-01"}'` |
|
|
57
|
+
|
|
58
|
+
### Uploading a receipt someone sent
|
|
59
|
+
|
|
60
|
+
A file the user sends arrives in `inbox/`. Pass its path **relative to the
|
|
61
|
+
current workspace** — never an absolute path, and never the file's contents:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
mrctl capability morning expense-upload '{"path":"inbox/receipt-2026-09-06.pdf"}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To attach the scan to an expense that already exists in Morning, add its id:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
mrctl capability morning expense-upload '{"path":"inbox/receipt.pdf","expenseId":"<expense id>"}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**A successful upload means Morning accepted the file, not that the expense
|
|
74
|
+
exists.** Morning reads the receipt asynchronously and creates a *draft*, which
|
|
75
|
+
a human approves in Morning's own interface. Say that — "it is in Morning
|
|
76
|
+
waiting for approval" — rather than "the expense was created". To see whether
|
|
77
|
+
the draft appeared, use `expense-draft-search`; the answer may take a minute.
|
|
78
|
+
|
|
79
|
+
Do not upload the same receipt twice to "make sure". Duplicates become two
|
|
80
|
+
drafts a human then has to untangle. Check with `expense-draft-search` instead.
|
|
55
81
|
|
|
56
82
|
### Document body
|
|
57
83
|
|
|
@@ -139,6 +165,10 @@ not reissue — the document is already there.
|
|
|
139
165
|
|
|
140
166
|
## Not supported yet
|
|
141
167
|
|
|
142
|
-
|
|
143
|
-
collection are not available through this
|
|
144
|
-
than attempting a workaround.
|
|
168
|
+
Creating an expense from typed-in details (`POST /expenses`), editing or closing
|
|
169
|
+
an existing expense, and payment collection are not available through this
|
|
170
|
+
skill. If asked, say so plainly rather than attempting a workaround.
|
|
171
|
+
|
|
172
|
+
Uploading a receipt file *is* supported — see `expense-upload` above. What is
|
|
173
|
+
not supported is entering an expense by hand: that needs an accounting
|
|
174
|
+
classification, which is the bookkeeper's judgment call, not the assistant's.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mercury-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Avishai Tsabari",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
"types": "./src/core/global-admin.ts",
|
|
25
25
|
"default": "./src/core/global-admin.ts"
|
|
26
26
|
},
|
|
27
|
+
"./core/media": {
|
|
28
|
+
"types": "./src/core/media.ts",
|
|
29
|
+
"default": "./src/core/media.ts"
|
|
30
|
+
},
|
|
27
31
|
"./core/model-leg": {
|
|
28
32
|
"types": "./src/core/model-leg.ts",
|
|
29
33
|
"default": "./src/core/model-leg.ts"
|
|
@@ -80,8 +84,8 @@
|
|
|
80
84
|
"typecheck": "bunx tsc --noEmit",
|
|
81
85
|
"typecheck:scripts": "bunx tsc --noEmit -p tsconfig.scripts.json",
|
|
82
86
|
"typecheck:examples": "bunx tsc --noEmit -p tsconfig.examples.json",
|
|
83
|
-
"lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
|
|
84
|
-
"lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
|
|
87
|
+
"lint": "bunx biome check src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts scripts/archive-erase-space.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
|
|
88
|
+
"lint:fix": "bunx biome check --fix src/ tests/ scripts/space-profile.ts scripts/football-harness.ts scripts/football-audit.ts scripts/ops-preflight.ts examples/extensions/archive/ examples/extensions/feed-watch/ examples/extensions/gws/ examples/extensions/napkin/ examples/extensions/pinchtab/ examples/extensions/overview/ examples/extensions/longview/ examples/extensions/yahoo-mail/ examples/extensions/morning/",
|
|
85
89
|
"check:silent-catch": "bun run scripts/check-silent-catch.ts",
|
|
86
90
|
"check:sync-calls": "bun run scripts/check-sync-calls.ts",
|
|
87
91
|
"check:dep-floors": "bun run scripts/check-dep-floors.ts",
|