mercury-agent 0.18.2 → 0.20.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.
Files changed (107) hide show
  1. package/container/Dockerfile +25 -0
  2. package/container/build.sh +23 -3
  3. package/docs/behavior-layers.md +25 -14
  4. package/docs/configuration.md +139 -11
  5. package/docs/container-lifecycle.md +151 -1
  6. package/docs/context-architecture.md +6 -2
  7. package/docs/extensions.md +9 -1
  8. package/docs/goals/football-reporter-profile/decisions.md +79 -0
  9. package/docs/goals/rehearsal-bench/decisions.md +41 -3
  10. package/docs/goals/rehearsal-bench/roadmap.md +3 -1
  11. package/docs/goals/release-gate/decisions.md +86 -0
  12. package/docs/goals/release-gate/roadmap.md +36 -3
  13. package/docs/live-testing.md +22 -0
  14. package/docs/pending-verification.md +178 -0
  15. package/docs/permissions.md +1 -1
  16. package/docs/profile-guide.md +28 -8
  17. package/examples/extensions/archive/backends/local.ts +6 -3
  18. package/examples/extensions/archive/queue.ts +51 -10
  19. package/examples/extensions/feed-watch/config.ts +42 -2
  20. package/examples/extensions/feed-watch/digest.ts +4 -7
  21. package/examples/extensions/feed-watch/items.ts +18 -7
  22. package/examples/extensions/feed-watch/match.ts +125 -9
  23. package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
  24. package/examples/extensions/feed-watch/watch.ts +39 -5
  25. package/examples/extensions/gws/index.ts +126 -8
  26. package/examples/extensions/longview/hook.ts +72 -7
  27. package/examples/extensions/longview/index.ts +2 -0
  28. package/examples/extensions/morning/README.md +26 -15
  29. package/examples/extensions/morning/index.ts +29 -6
  30. package/examples/extensions/morning/lib/hosts.ts +16 -0
  31. package/examples/extensions/morning/lib/morning.ts +78 -0
  32. package/examples/extensions/morning/lib/upload.ts +584 -0
  33. package/examples/extensions/morning/skill/SKILL.md +34 -4
  34. package/examples/extensions/napkin/index.ts +12 -3
  35. package/examples/extensions/napkin/pi-spawn.ts +5 -1
  36. package/examples/extensions/overview/index.ts +20 -0
  37. package/examples/extensions/overview/skill/SKILL.md +9 -1
  38. package/examples/extensions/pinchtab/index.ts +36 -7
  39. package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
  40. package/examples/profiles/_template/AGENTS.md +6 -1
  41. package/examples/profiles/football-reporter/AGENTS.md +16 -4
  42. package/examples/profiles/football-reporter/README.md +1 -1
  43. package/examples/profiles/football-reporter/config.yaml +42 -6
  44. package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
  45. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
  46. package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
  47. package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
  48. package/examples/profiles/football-reporter/standard.json +45 -8
  49. package/package.json +11 -6
  50. package/resources/skills/tasks/SKILL.md +17 -1
  51. package/resources/templates/AGENTS.md +4 -3
  52. package/resources/templates/mercury.example.yaml +13 -4
  53. package/src/adapters/whatsapp-ingress.ts +15 -3
  54. package/src/agent/container-entry.ts +299 -29
  55. package/src/agent/container-env.ts +26 -8
  56. package/src/agent/container-runner.ts +497 -76
  57. package/src/agent/image-contract.ts +77 -0
  58. package/src/agent/image-manifest.ts +180 -0
  59. package/src/agent/image-refresh.ts +318 -0
  60. package/src/cli/mercury.ts +225 -28
  61. package/src/cli/mrctl-http.ts +5 -0
  62. package/src/cli/mrctl.ts +57 -5
  63. package/src/cli/service-unit.ts +109 -0
  64. package/src/config-file.ts +22 -1
  65. package/src/config.ts +105 -14
  66. package/src/core/api.ts +11 -2
  67. package/src/core/commands.ts +11 -4
  68. package/src/core/connection-health.ts +377 -0
  69. package/src/core/direct-send.ts +235 -14
  70. package/src/core/exec.ts +10 -0
  71. package/src/core/history-window.ts +142 -0
  72. package/src/core/model-command.ts +130 -0
  73. package/src/core/operator-alerts.ts +326 -23
  74. package/src/core/permissions.ts +28 -0
  75. package/src/core/profiles.ts +32 -16
  76. package/src/core/reply-context.ts +31 -0
  77. package/src/core/routes/config-builtin.ts +11 -0
  78. package/src/core/routes/console.ts +98 -16
  79. package/src/core/routes/dashboard.ts +93 -10
  80. package/src/core/routes/model.ts +26 -3
  81. package/src/core/routes/send.ts +1 -1
  82. package/src/core/routes/tasks.ts +74 -0
  83. package/src/core/runtime.ts +302 -21
  84. package/src/core/system-messages.ts +33 -0
  85. package/src/core/task-scheduler.ts +155 -7
  86. package/src/extensions/image-builder.ts +1 -1
  87. package/src/extensions/installer.ts +72 -17
  88. package/src/extensions/load-project.ts +74 -0
  89. package/src/extensions/loader.ts +50 -9
  90. package/src/host-version.ts +32 -0
  91. package/src/main.ts +42 -21
  92. package/src/preflight/checks/credential.ts +300 -0
  93. package/src/preflight/checks/docker.ts +149 -0
  94. package/src/preflight/checks/extensions.ts +90 -0
  95. package/src/preflight/checks/host-deps.ts +247 -0
  96. package/src/preflight/checks/image-contract.ts +228 -0
  97. package/src/preflight/checks/roundtrip.ts +424 -0
  98. package/src/preflight/checks/sandbox.ts +159 -0
  99. package/src/preflight/deps.ts +107 -0
  100. package/src/preflight/probe-container.ts +156 -0
  101. package/src/preflight/report.ts +177 -0
  102. package/src/preflight/run.ts +223 -0
  103. package/src/server.ts +55 -14
  104. package/src/storage/db.ts +41 -2
  105. package/src/storage/models-json.ts +110 -0
  106. package/src/text/reporter-lint.ts +167 -23
  107. package/src/types.ts +22 -0
@@ -15,6 +15,8 @@ type ConnectionAuthType =
15
15
  | "credentials-file"
16
16
  | "custom";
17
17
 
18
+ type ConnectionStatus = "connected" | "needs-reauth" | "broken" | "unknown";
19
+
18
20
  type MercuryExt = {
19
21
  cli(opts: { name: string; install: string }): void;
20
22
  permission(opts: { defaultRoles: string[] }): void;
@@ -27,6 +29,7 @@ type MercuryExt = {
27
29
  authType: ConnectionAuthType;
28
30
  credentialEnvVar?: string;
29
31
  scopes?: string[];
32
+ statusCheck?: () => Promise<{ status: ConnectionStatus; detail?: string }>;
30
33
  }): void;
31
34
  on(
32
35
  event: "before_container",
@@ -34,6 +37,7 @@ type MercuryExt = {
34
37
  event: { spaceId: string; callerId: string },
35
38
  ctx: {
36
39
  db: { getExtState(e: string, k: string): string | null };
40
+ log: { warn(msg: string, extra?: unknown): void };
37
41
  hasCallerPermission(
38
42
  spaceId: string,
39
43
  callerId: string,
@@ -55,6 +59,67 @@ const gwsEnv = {
55
59
  /** Path where credentials are materialized inside the inner container's own /tmp. */
56
60
  const CREDENTIALS_FILE = "/tmp/gws-credentials.json";
57
61
 
62
+ /**
63
+ * The string fields google-auth-library needs on an ADC "authorized user"
64
+ * document, beyond `type`. `type` is checked separately so its absence can be
65
+ * named on its own — it is the field operators actually hit.
66
+ */
67
+ const REQUIRED_CREDENTIAL_FIELDS = [
68
+ "client_id",
69
+ "client_secret",
70
+ "refresh_token",
71
+ ] as const;
72
+
73
+ /**
74
+ * Describe what is wrong with a candidate credential, or `undefined` when it is
75
+ * a usable ADC "authorized user" document.
76
+ *
77
+ * A parse check is not enough, which is what this guard exists to fix. Google's
78
+ * `/token` response is valid JSON carrying a working `refresh_token`, but has no
79
+ * top-level `type`, so it sails through `JSON.parse` and then fails on *every*
80
+ * `gws` invocation with "the file doesn't contain the field 'type'" — from
81
+ * inside a conversation, where no operator sees it. `{}` passed too. Read the
82
+ * guard by asking what happens when a field is missing, and make absence deny.
83
+ */
84
+ function credentialProblem(raw: string): string | undefined {
85
+ let parsed: unknown;
86
+ try {
87
+ parsed = JSON.parse(raw);
88
+ } catch {
89
+ return "is not valid JSON";
90
+ }
91
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
92
+ return "is not a JSON object";
93
+ }
94
+ const doc = parsed as Record<string, unknown>;
95
+ if (doc.type === undefined) {
96
+ return 'is missing the "type" field (expected "authorized_user"; note that "token_type" is a different field and does not satisfy it)';
97
+ }
98
+ if (doc.type !== "authorized_user") {
99
+ return `has type ${JSON.stringify(doc.type)} (expected "authorized_user")`;
100
+ }
101
+ const missing = REQUIRED_CREDENTIAL_FIELDS.filter(
102
+ (field) => typeof doc[field] !== "string" || doc[field] === "",
103
+ );
104
+ if (missing.length > 0) {
105
+ return `is missing or has empty ${missing.join(", ")}`;
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Appended to every rejection so the log names the fix, not just the fault. */
111
+ const CREDENTIAL_RECOVERY =
112
+ 'Expected {"type":"authorized_user","client_id":...,"client_secret":...,"refresh_token":...}. ' +
113
+ "Regenerate with: gws auth login && gws auth export --unmasked";
114
+
115
+ /**
116
+ * Spaces whose permission denial has already been logged this process. Bounded
117
+ * by the number of spaces on the host, and deliberately not persisted: one
118
+ * reminder per restart is what makes a misconfiguration findable without
119
+ * turning the routine case into per-message noise.
120
+ */
121
+ const permissionDenialLogged = new Set<string>();
122
+
58
123
  export default function (mercury: MercuryExt) {
59
124
  // The gws CLI can only take refresh-token credentials from a file, so someone
60
125
  // has to materialize GWS_CREDENTIALS_JSON before the first command runs. The
@@ -81,11 +146,12 @@ export default function (mercury: MercuryExt) {
81
146
  "'if [ -n \"$GWS_CREDENTIALS_JSON\" ]; then' " +
82
147
  `' c="\${GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE:-${CREDENTIALS_FILE}}"' ` +
83
148
  "' t=\"$c.$$\"' " +
84
- "' (umask 077; printf %s \"$GWS_CREDENTIALS_JSON\" > \"$t\")' " +
149
+ '\' (umask 077; printf %s "$GWS_CREDENTIALS_JSON" > "$t")\' ' +
85
150
  // Credentials changed (rotation, or a different caller in a reused
86
151
  // container) invalidates the cached access token alongside them.
87
- "' cmp -s \"$t\" \"$c\" 2>/dev/null || rm -f \"${GOOGLE_WORKSPACE_CLI_CONFIG_DIR:-$HOME/.config/gws}/token_cache.json\"' " +
88
- "' mv -f \"$t\" \"$c\"' " +
152
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion inside a single-quoted sh fragment — a template literal here would be the bug
153
+ '\' cmp -s "$t" "$c" 2>/dev/null || rm -f "${GOOGLE_WORKSPACE_CLI_CONFIG_DIR:-$HOME/.config/gws}/token_cache.json"\' ' +
154
+ '\' mv -f "$t" "$c"\' ' +
89
155
  "' GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=\"$c\"; export GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE' " +
90
156
  "'fi' " +
91
157
  "'exec /usr/local/bin/gws-real \"$@\"' " +
@@ -112,21 +178,73 @@ export default function (mercury: MercuryExt) {
112
178
  "https://www.googleapis.com/auth/spreadsheets",
113
179
  "https://www.googleapis.com/auth/userinfo.email",
114
180
  ],
181
+ // Without this, the connection reports "connected" on presence alone, so a
182
+ // credential the CLI cannot use still shows green on the dashboard and the
183
+ // only symptom is a failed tool call mid-conversation.
184
+ //
185
+ // Shape only. Validity — is the refresh token still live? — cannot be
186
+ // answered here: this probe runs on demand, when someone opens the
187
+ // dashboard, and a dead credential's whole problem is that nobody looks.
188
+ // That question belongs to the daily refresh-token exchange in
189
+ // src/core/connection-health.ts, which alerts an operator out of band.
190
+ // If a rule is added below, mirror it in `readGoogleCredential` there.
191
+ statusCheck: async () => {
192
+ const raw = process.env[gwsEnv.env.credentials];
193
+ // Match the runtime's own presence default so an unconfigured connection
194
+ // reads the same with or without this probe.
195
+ if (!raw) {
196
+ return {
197
+ status: "unknown",
198
+ detail: `${gwsEnv.env.credentials} is not set`,
199
+ };
200
+ }
201
+ const problem = credentialProblem(raw);
202
+ if (problem) {
203
+ return {
204
+ status: "broken",
205
+ detail: `${gwsEnv.env.credentials} ${problem}`,
206
+ };
207
+ }
208
+ return { status: "connected" };
209
+ },
115
210
  });
116
211
 
117
212
  // Inner containers don't share the outer container's volume — they mount host
118
213
  // filesystem paths. So we can't write a credentials file from this hook and
119
214
  // have the inner container see it. Instead, pass the target path via env var
120
215
  // and let the skill materialize the file from GWS_CREDENTIALS_JSON at runtime.
121
- mercury.on("before_container", async () => {
216
+ mercury.on("before_container", async (event, ctx) => {
122
217
  const raw = process.env[gwsEnv.env.credentials];
123
218
  if (!raw) return undefined;
124
219
 
125
- try {
126
- JSON.parse(raw);
127
- } catch {
220
+ // GWS_CREDENTIALS_JSON is delivered by THIS hook, not by mercury.env(), and
221
+ // the two paths are not gated alike: a declared env var is injected only for
222
+ // a caller holding the extension's permission, while a hook's returned env
223
+ // is merged in `emitBeforeContainer` and pushed straight onto the container's
224
+ // `-e` list with no RBAC in between. So `mercury.permission()` above gates
225
+ // the *skill*, not this credential — without the check here the refresh
226
+ // token (gmail.modify, drive, calendar, documents, spreadsheets) reaches
227
+ // every caller in every space. The permission name is the extension name.
228
+ if (!ctx.hasCallerPermission(event.spaceId, event.callerId, "gws")) {
229
+ // Silence here is the confusing failure: the skill is present in the
230
+ // container, the credentials are not, and every Google call fails with
231
+ // an auth error that names nothing. One line per space per process —
232
+ // this branch is the normal case in every space that does not use gws,
233
+ // so logging per message would drown the log. No callerId: it is a
234
+ // phone number / LID, and the space is what an operator acts on.
235
+ if (!permissionDenialLogged.has(event.spaceId)) {
236
+ permissionDenialLogged.add(event.spaceId);
237
+ ctx.log.warn(
238
+ `[gws.before_container] credentials withheld for space ${event.spaceId}: caller lacks the "gws" permission. If Google Workspace is meant to work there, add "gws" to the caller's role with \`mrctl permissions set <role> ...\` from that space.`,
239
+ );
240
+ }
241
+ return undefined;
242
+ }
243
+
244
+ const problem = credentialProblem(raw);
245
+ if (problem) {
128
246
  console.error(
129
- `[gws.before_container] ${gwsEnv.env.credentials} is not valid JSON — skipping`,
247
+ `[gws.before_container] ${gwsEnv.env.credentials} ${problem} — skipping. ${CREDENTIAL_RECOVERY}`,
130
248
  );
131
249
  return undefined;
132
250
  }
@@ -148,6 +148,18 @@ export function resolveThreshold(raw: string | null): number {
148
148
  const SUMMARY_BLOCK =
149
149
  /^\s*\[longview:summary\]\r?\n?([\s\S]*?)\r?\n?\[\/longview:summary\][^\S\r\n]*\r?\n?/;
150
150
 
151
+ /**
152
+ * A block the author opened, filled with one paragraph, and forgot to close:
153
+ * opener, a non-empty first line, then a blank line.
154
+ *
155
+ * The paragraph boundary is the whole point — "opener to end of text" would
156
+ * make the entire reply the summary. The capture is forced to start on a
157
+ * non-newline character so that an opener followed *immediately* by a blank
158
+ * line cannot slide forward and claim the article's first paragraph instead.
159
+ */
160
+ const UNTERMINATED_BLOCK =
161
+ /^\s*\[longview:summary\][^\S\r\n]*\r?\n([^\r\n][\s\S]*?)\r?\n[^\S\r\n]*\r?\n/;
162
+
151
163
  /** The opening marker on its own, used to recognise a truncated generation. */
152
164
  const OPENS_BLOCK = /^\s*\[longview:summary\]/;
153
165
 
@@ -177,6 +189,28 @@ export interface AuthorSummary {
177
189
  rest: string;
178
190
  /** True when the body was over the cap and had to be cut. */
179
191
  truncated: boolean;
192
+ /**
193
+ * True when the closing marker was missing and the first paragraph was taken
194
+ * as the summary. The caller logs it: the author's intent was honoured, but
195
+ * the reply was malformed and that has to be visible.
196
+ */
197
+ unterminated: boolean;
198
+ }
199
+
200
+ /** Cap the body and assemble the result — shared by both block shapes. */
201
+ function toAuthorSummary(
202
+ body: string,
203
+ rest: string,
204
+ maxChars: number,
205
+ unterminated: boolean,
206
+ ): AuthorSummary {
207
+ const truncated = [...body].length > maxChars;
208
+ return {
209
+ summary: truncated ? cutAtWordBoundary(body, maxChars) : body,
210
+ rest,
211
+ truncated,
212
+ unterminated,
213
+ };
180
214
  }
181
215
 
182
216
  /**
@@ -186,25 +220,39 @@ export interface AuthorSummary {
186
220
  * editorial standard, so no second model call is needed and nothing has to be
187
221
  * re-derived from the finished page. Returns `undefined` when there is no
188
222
  * block, which is every reply from every space that does not ask for one.
223
+ *
224
+ * A block whose closer never arrived is still the author's intent, so the
225
+ * closed shape is tried first and the unterminated one second. Only a reply
226
+ * with no blank line after the opener — a generation cut off mid-block — is
227
+ * left to `stripStrayMarkers`.
189
228
  */
190
229
  export function extractAuthorSummary(
191
230
  reply: string,
192
231
  maxChars: number,
193
232
  ): AuthorSummary | undefined {
194
233
  const match = SUMMARY_BLOCK.exec(reply);
195
- if (!match) return undefined;
234
+ if (!match) {
235
+ const open = UNTERMINATED_BLOCK.exec(reply);
236
+ const paragraph = (open?.[1] ?? "").trim();
237
+ // No paragraph means nothing to send; falling through leaves the existing
238
+ // stray-marker path in charge, exactly as before this branch existed.
239
+ if (!open || !paragraph) return undefined;
240
+ return toAuthorSummary(
241
+ paragraph,
242
+ // There is no closer anywhere in the reply (one would have matched
243
+ // `SUMMARY_BLOCK`), but a second opener further down still has to go.
244
+ reply.slice(open[0].length).replace(STRAY_MARKER, ""),
245
+ maxChars,
246
+ true,
247
+ );
248
+ }
196
249
 
197
250
  // An empty block is malformed output, but it is still a block: returning
198
251
  // `undefined` here would leave the markers in the text that gets published.
199
252
  // The hook falls back to generating a blurb from what is left.
200
253
  const body = (match[1] ?? "").trim();
201
254
 
202
- const truncated = [...body].length > maxChars;
203
- return {
204
- summary: truncated ? cutAtWordBoundary(body, maxChars) : body,
205
- rest: reply.slice(match[0].length),
206
- truncated,
207
- };
255
+ return toAuthorSummary(body, reply.slice(match[0].length), maxChars, false);
208
256
  }
209
257
 
210
258
  // ---------------------------------------------------------------------------
@@ -291,7 +339,24 @@ export async function handleAfterContainer(
291
339
  maxChars,
292
340
  });
293
341
  }
342
+ if (author?.unterminated) {
343
+ // Honoured, not silent: the summary was recovered, but a reply that never
344
+ // closed its block is malformed output the author has to be able to see.
345
+ log.warn(
346
+ "longview: summary block was never closed; took its first paragraph as the summary",
347
+ { spaceId },
348
+ );
349
+ }
294
350
  const body = author ? author.rest : stripStrayMarkers(reply);
351
+ if (!author && body !== reply) {
352
+ // An opener with no blank line after it — a generation cut off mid-block.
353
+ // The markers came off, but nothing was recoverable, so the whole reply
354
+ // (summary paragraph included) is what gets measured and published.
355
+ log.warn(
356
+ "longview: reply opened a summary block and never closed it; markers stripped",
357
+ { spaceId },
358
+ );
359
+ }
295
360
 
296
361
  // Count code points: `threshold_chars` should mean characters a reader sees,
297
362
  // and Hebrew and emoji are the normal case for this extension.
@@ -49,6 +49,7 @@ import { renderRecentWidget } from "./widget.js";
49
49
  const SUMMARIZE_PROMPT_PATH = join(import.meta.dir, "prompts", "summarize.md");
50
50
 
51
51
  export default function (mercury: {
52
+ // biome-ignore lint/suspicious/noExplicitAny: hand-written structural stand-in for MercuryExt so the extension loads without the host's types
52
53
  on(event: string, handler: (event: any, ctx: any) => Promise<any>): void;
53
54
  config(
54
55
  key: string,
@@ -58,6 +59,7 @@ export default function (mercury: {
58
59
  validate?: (v: string) => boolean;
59
60
  },
60
61
  ): void;
62
+ // biome-ignore lint/suspicious/noExplicitAny: same structural stand-in
61
63
  widget(def: { label: string; render: (ctx: any) => string }): void;
62
64
  store: {
63
65
  get(key: string): string | null;
@@ -41,22 +41,23 @@ mrctl config set morning.environment sandbox # or: production
41
41
 
42
42
  Only those two values are accepted. A typo is refused at set time.
43
43
 
44
- ## Two things that will look like bugs
44
+ ## Why this connection is not marked `sensitive`
45
45
 
46
- **1. In a WhatsApp group, every turn is blocked until you enable sensitive
47
- connections.** This connection is declared `sensitive: true` because it can
48
- issue legally binding tax documents. In a group-linked space Mercury then
49
- refuses assistant turns unless the space opts in, and asks each caller to
50
- confirm:
46
+ It issues legally binding tax documents, so `sensitive: true` looks right. It is
47
+ deliberately omitted, because the runtime's sensitive guard is host-global and
48
+ space-blind: one sensitive connection anywhere on the host puts *every*
49
+ group-linked space behind a per-message confirmation, and the confirmation is
50
+ consumed on each `yes`, so it repeats for every turn forever. A host with one
51
+ accounting space and thirteen unrelated ones would pay that cost fourteen times
52
+ over.
51
53
 
52
- ```bash
53
- mrctl config set security.sensitive_connections_allowed true
54
- ```
54
+ Nothing load-bearing is lost. The credentials are `hostOnly` and reached only
55
+ through the broker, so they never enter an agent container, and the verbs are
56
+ `admin`-only. Restore the flag once the guard scopes per-space or per-extension.
55
57
 
56
- This is intended, not a fault. In a one-to-one conversation the guard does not
57
- apply.
58
+ ## One thing that will look like a bug
58
59
 
59
- **2. Issuing requires a preview first.** `document-create` refuses without an
60
+ **Issuing requires a preview first.** `document-create` refuses without an
60
61
  `issueToken` returned by `document-preview` of the *same* document. The token is
61
62
  bound to the payload, the environment, the space and the caller, and works once.
62
63
  A retry after a successful create is refused rather than issuing twice.
@@ -93,7 +94,16 @@ mrctl capability morning <action> '<json>'
93
94
  ```
94
95
 
95
96
  `client-search`, `client-create`, `document-types`, `document-preview`,
96
- `document-create`, `document-search`, `document-links`.
97
+ `document-create`, `document-search`, `document-links`, `expense-upload`,
98
+ `expense-draft-search`.
99
+
100
+ **`expense-upload` takes a path, never bytes.** The container names a file
101
+ relative to its own space workspace (`inbox/receipt.pdf`); the host reads it and
102
+ confines the path to `<spacesDir>/<spaceId>/`, resolving symlinks *before* the
103
+ containment check — `inbox/` is written by a lower-trust boundary, so a symlink
104
+ there pointing at the host's `.env` is the attack the guard exists for. A
105
+ refused path returns one generic message and logs the detail host-side at WARN;
106
+ telling the container why would make the guard a filesystem oracle.
97
107
 
98
108
  Full request shapes and the issue flow are in `skill/SKILL.md`; enums and the
99
109
  240-row error table are in `skill/references/`.
@@ -102,8 +112,9 @@ Full request shapes and the issue flow are in `skill/SKILL.md`; enums and the
102
112
 
103
113
  | Area | Why |
104
114
  |---|---|
105
- | Expenses, receipt upload | `POST /expenses` requires an `accountingClassification`, and the "Get Accounting Classifications" endpoint its schema references is absent from the 53 published paths. Also, an uploaded file always becomes a *draft* and there is no API to approve a draft. |
106
- | Webhooks (`payment/received`, `document/created`) | Extensions cannot register HTTP routes. Needs a core route or a polling job. |
115
+ | Creating an expense from typed-in details (`POST /expenses`) | Requires an `accountingClassification`, and the "Get Accounting Classifications" endpoint its schema references is still absent from the published paths (re-verified 2026-09-06). Classifying inline would mean guessing a tax category on the user's behalf. Receipt **upload** sidesteps this entirely and is implemented — it creates a draft Morning parses itself, which a human approves in Morning's UI. |
116
+ | Editing, closing or deleting an expense | Reading and creating drafts is the chore; mutating existing expenses is a larger trust decision. |
117
+ | Webhooks (`payment/received`, `document/created`, `expense-draft/parsed`) | Extensions cannot register HTTP routes. This is why `expense-upload` cannot report whether a draft was ultimately parsed or declined — poll `expense-draft-search` instead. |
107
118
  | Payments API, credit-card tokens | Moving money is a larger trust decision than issuing a document. |
108
119
  | Partners API | For multi-business accountants; this is scoped to one business per key set. |
109
120
 
@@ -12,6 +12,7 @@
12
12
  * without an accounting-classification source.
13
13
  */
14
14
 
15
+ import { isAbsolute, join } from "node:path";
15
16
  import { runAction } from "./lib/morning.js";
16
17
  import { authErrorKey } from "./lib/token.js";
17
18
 
@@ -43,6 +44,9 @@ type ExtCtx = {
43
44
  listExtState(e: string): Array<{ key: string; value: string }>;
44
45
  };
45
46
  getConfig(spaceId: string, key: string): string | null;
47
+ /** Structural subset of `AppConfig` — only what this extension reads. */
48
+ config: { spacesDir: string };
49
+ log: { warn(message: string, meta?: Record<string, unknown>): void };
46
50
  };
47
51
 
48
52
  /** Structural match for `MercuryExtensionAPI` (avoid package subpath imports). */
@@ -131,12 +135,22 @@ export default function (mercury: MercuryExt) {
131
135
  // from the env var rather than from stored state makes the dashboard
132
136
  // correct before the first token is ever minted.
133
137
  credentialEnvVar: CLIENT_ID_VAR,
134
- // This connection can issue tax documents. In a group-linked space the
135
- // runtime then requires explicit admin enablement
136
- // (security.sensitive_connections_allowed) plus per-caller confirmation —
137
- // see src/core/runtime.ts. That operational cost is intended; the README
138
- // says so, because the symptom otherwise reads as a broken extension.
139
- sensitive: true,
138
+ // NOT `sensitive: true`, despite issuing tax documents — but no longer
139
+ // because the guard is broken. Both original objections are fixed as of
140
+ // 7bd050f: getActiveSensitiveConnectionName() now takes the caller's
141
+ // space and role and skips any connection that caller cannot reach, and a
142
+ // given confirmation is recorded in its own row so it sticks instead of
143
+ // re-asking on every message. Turning this on today would put admin
144
+ // callers in morning-permitted spaces behind a one-time confirmation, and
145
+ // nothing else — which is a defensible thing to want.
146
+ //
147
+ // It stays off until that is decided deliberately, because the
148
+ // load-bearing controls are elsewhere and already hold: the credentials
149
+ // are hostOnly and brokered (rung 4), so they never enter a container, and
150
+ // mercury.permission({defaultRoles:["admin"]}) keeps the verbs admin-only.
151
+ // The confirmation would add a prompt, not a boundary. Flipping this is a
152
+ // one-line change with a live-behaviour consequence, so make it on
153
+ // purpose.
140
154
  // Side-effect free and fast, per the ConnectionDef contract (5s timeout):
141
155
  // it reports from stored state and never mints a token or makes a request.
142
156
  statusCheck: async (ctx) => {
@@ -167,6 +181,15 @@ export default function (mercury: MercuryExt) {
167
181
  spaceId: req.spaceId,
168
182
  callerId: req.callerId,
169
183
  environment: ctx.getConfig(req.spaceId, ENVIRONMENT_KEY),
184
+ // `expense-upload` reads a file the container named, so it needs the
185
+ // workspace root to confine that path to the caller's own space.
186
+ // `resolveProjectPath` is not an exported subpath of the package; this
187
+ // mirrors it (src/config.ts) rather than widening the package API for
188
+ // two lines. Keep the two in step.
189
+ spacesDir: isAbsolute(ctx.config.spacesDir)
190
+ ? ctx.config.spacesDir
191
+ : join(process.cwd(), ctx.config.spacesDir),
192
+ logDenial: (message, detail) => ctx.log.warn(message, detail),
170
193
  });
171
194
  });
172
195
 
@@ -15,6 +15,14 @@
15
15
  * credential sets. A production key cannot mint against sandbox.
16
16
  * 3. Sandbox's API host is a `greeninvoice.co.il` name while its token host is
17
17
  * a `morning.dev` name. They do not share a domain; do not "tidy" them.
18
+ *
19
+ * A fourth, added 2026-09-06 and verified the same way: `/file-upload/v1/url`
20
+ * carries PATH-LEVEL `servers` in the spec that override the global ones, so it
21
+ * is not reachable under `apiBase` at all — hence `uploadBase`. That makes three
22
+ * distinct sandbox hosts (`api.sandbox.morning.dev`,
23
+ * `sandbox.d.greeninvoice.co.il`, `api.sandbox.d.greeninvoice.co.il`) which read
24
+ * like typos of one another and are not. `tests/morning-extension.test.ts`
25
+ * asserts all six as literals so a tidy-up cannot collapse them.
18
26
  */
19
27
 
20
28
  export const MORNING_EXT = "morning" as const;
@@ -27,16 +35,24 @@ export interface MorningHosts {
27
35
  readonly tokenUrl: string;
28
36
  /** API base, no trailing slash. Every non-token call is `${apiBase}/<path>`. */
29
37
  readonly apiBase: string;
38
+ /**
39
+ * Base for the expense file-upload endpoint only, no trailing slash. A
40
+ * different origin from `apiBase` and without the `/api/v1` prefix — see the
41
+ * fourth note above.
42
+ */
43
+ readonly uploadBase: string;
30
44
  }
31
45
 
32
46
  const HOSTS: Record<MorningEnvironment, MorningHosts> = {
33
47
  production: {
34
48
  tokenUrl: "https://api.morning.co/idp/v1/oauth/token",
35
49
  apiBase: "https://api.greeninvoice.co.il/api/v1",
50
+ uploadBase: "https://api.morning.co",
36
51
  },
37
52
  sandbox: {
38
53
  tokenUrl: "https://api.sandbox.morning.dev/idp/v1/oauth/token",
39
54
  apiBase: "https://sandbox.d.greeninvoice.co.il/api/v1",
55
+ uploadBase: "https://api.sandbox.d.greeninvoice.co.il",
40
56
  },
41
57
  };
42
58
 
@@ -29,6 +29,7 @@ import {
29
29
  mintIssueToken,
30
30
  } from "./issue-token.js";
31
31
  import { getAccessToken, recordAuthError, type TokenStore } from "./token.js";
32
+ import { expenseUpload as runExpenseUpload } from "./upload.js";
32
33
 
33
34
  export interface CapabilityResult {
34
35
  status?: number;
@@ -43,6 +44,14 @@ export interface ActionContext {
43
44
  spaceId: string;
44
45
  callerId: string;
45
46
  fetchImpl?: typeof fetch;
47
+ /**
48
+ * Root of all space workspaces, project-resolved by the caller. Only
49
+ * `expense-upload` needs it; absent means that verb refuses rather than
50
+ * guessing a root.
51
+ */
52
+ spacesDir?: string;
53
+ /** Host-side WARN channel for refusals that must not be detailed to the caller. */
54
+ logDenial?: (message: string, detail: Record<string, unknown>) => void;
46
55
  }
47
56
 
48
57
  type Body = Record<string, unknown>;
@@ -439,6 +448,73 @@ async function documentSearch(ctx: ActionContext, body: Body) {
439
448
  });
440
449
  }
441
450
 
451
+ async function expenseDraftSearch(ctx: ActionContext, body: Body) {
452
+ const { page, pageSize } = paging(body);
453
+ return apiCall(ctx, "POST", "/expenses/drafts/search", {
454
+ ...pick(body, [
455
+ "fromDate",
456
+ "toDate",
457
+ "description",
458
+ "supplierId",
459
+ "supplierName",
460
+ ]),
461
+ page,
462
+ pageSize,
463
+ });
464
+ }
465
+
466
+ /**
467
+ * Upload a receipt as an expense draft.
468
+ *
469
+ * Two hops to two different hosts, so it cannot be expressed as one `apiCall`.
470
+ * The token PROVIDER is handed over rather than a token: the retry policy lives
471
+ * in `upload.ts`, which is the only place that knows step 1 is safe to repeat
472
+ * and step 2 is not.
473
+ */
474
+ async function expenseUpload(ctx: ActionContext, body: Body) {
475
+ if (!ctx.spacesDir) {
476
+ return {
477
+ status: 503,
478
+ data: {
479
+ error:
480
+ "expense-upload is unavailable: the host did not supply a workspace root.",
481
+ },
482
+ };
483
+ }
484
+
485
+ return runExpenseUpload(
486
+ {
487
+ env: ctx.env,
488
+ spaceId: ctx.spaceId,
489
+ spacesDir: ctx.spacesDir,
490
+ ...(ctx.fetchImpl ? { fetchImpl: ctx.fetchImpl } : {}),
491
+ ...(ctx.logDenial ? { logDenial: ctx.logDenial } : {}),
492
+ },
493
+ body,
494
+ {
495
+ async getToken(forceRefresh: boolean) {
496
+ const result = await getAccessToken(ctx.db, ctx.env, {
497
+ forceRefresh,
498
+ ...(ctx.fetchImpl ? { fetchImpl: ctx.fetchImpl } : {}),
499
+ });
500
+ if (result.ok) return { ok: true as const, token: result.accessToken };
501
+ if (result.kind === "auth_failed") {
502
+ recordAuthError(ctx.db, ctx.env, result.message);
503
+ }
504
+ return {
505
+ ok: false as const,
506
+ result: {
507
+ status: result.kind === "not_configured" ? 503 : 502,
508
+ data: { error: result.message, kind: result.kind },
509
+ },
510
+ };
511
+ },
512
+ recordAuthError: (message: string) =>
513
+ recordAuthError(ctx.db, ctx.env, message),
514
+ },
515
+ );
516
+ }
517
+
442
518
  async function documentLinks(ctx: ActionContext, body: Body) {
443
519
  const id = typeof body.id === "string" ? body.id.trim() : "";
444
520
  if (!id) return usage("document-links requires an id");
@@ -462,6 +538,8 @@ const ACTIONS: Record<
462
538
  "document-create": documentCreate,
463
539
  "document-search": documentSearch,
464
540
  "document-links": documentLinks,
541
+ "expense-upload": expenseUpload,
542
+ "expense-draft-search": expenseDraftSearch,
465
543
  };
466
544
 
467
545
  export const AVAILABLE_ACTIONS = Object.keys(ACTIONS);