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.
- package/container/Dockerfile +25 -0
- package/container/build.sh +23 -3
- package/docs/behavior-layers.md +25 -14
- package/docs/configuration.md +139 -11
- package/docs/container-lifecycle.md +151 -1
- package/docs/context-architecture.md +6 -2
- package/docs/extensions.md +9 -1
- package/docs/goals/football-reporter-profile/decisions.md +79 -0
- 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 +86 -0
- package/docs/goals/release-gate/roadmap.md +36 -3
- package/docs/live-testing.md +22 -0
- package/docs/pending-verification.md +178 -0
- package/docs/permissions.md +1 -1
- package/docs/profile-guide.md +28 -8
- package/examples/extensions/archive/backends/local.ts +6 -3
- package/examples/extensions/archive/queue.ts +51 -10
- package/examples/extensions/feed-watch/config.ts +42 -2
- package/examples/extensions/feed-watch/digest.ts +4 -7
- package/examples/extensions/feed-watch/items.ts +18 -7
- package/examples/extensions/feed-watch/match.ts +125 -9
- package/examples/extensions/feed-watch/skill/SKILL.md +25 -0
- package/examples/extensions/feed-watch/watch.ts +39 -5
- package/examples/extensions/gws/index.ts +126 -8
- package/examples/extensions/longview/hook.ts +72 -7
- package/examples/extensions/longview/index.ts +2 -0
- package/examples/extensions/morning/README.md +26 -15
- package/examples/extensions/morning/index.ts +29 -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/examples/extensions/napkin/index.ts +12 -3
- package/examples/extensions/napkin/pi-spawn.ts +5 -1
- package/examples/extensions/overview/index.ts +20 -0
- package/examples/extensions/overview/skill/SKILL.md +9 -1
- package/examples/extensions/pinchtab/index.ts +36 -7
- package/examples/extensions/pinchtab/skill/SKILL.md +28 -1
- package/examples/profiles/_template/AGENTS.md +6 -1
- package/examples/profiles/football-reporter/AGENTS.md +16 -4
- package/examples/profiles/football-reporter/README.md +1 -1
- package/examples/profiles/football-reporter/config.yaml +42 -6
- package/examples/profiles/football-reporter/seed/MEMORY.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +1 -1
- package/examples/profiles/football-reporter/seed/episodes/maccabi-tel-aviv-2026-27.md +24 -0
- package/examples/profiles/football-reporter/seed/napkin-distill.md +30 -29
- package/examples/profiles/football-reporter/standard.json +45 -8
- package/package.json +11 -6
- package/resources/skills/tasks/SKILL.md +17 -1
- package/resources/templates/AGENTS.md +4 -3
- package/resources/templates/mercury.example.yaml +13 -4
- package/src/adapters/whatsapp-ingress.ts +15 -3
- package/src/agent/container-entry.ts +299 -29
- package/src/agent/container-env.ts +26 -8
- package/src/agent/container-runner.ts +497 -76
- package/src/agent/image-contract.ts +77 -0
- package/src/agent/image-manifest.ts +180 -0
- package/src/agent/image-refresh.ts +318 -0
- package/src/cli/mercury.ts +225 -28
- package/src/cli/mrctl-http.ts +5 -0
- package/src/cli/mrctl.ts +57 -5
- package/src/cli/service-unit.ts +109 -0
- package/src/config-file.ts +22 -1
- package/src/config.ts +105 -14
- package/src/core/api.ts +11 -2
- package/src/core/commands.ts +11 -4
- package/src/core/connection-health.ts +377 -0
- package/src/core/direct-send.ts +235 -14
- package/src/core/exec.ts +10 -0
- package/src/core/history-window.ts +142 -0
- package/src/core/model-command.ts +130 -0
- package/src/core/operator-alerts.ts +326 -23
- package/src/core/permissions.ts +28 -0
- package/src/core/profiles.ts +32 -16
- package/src/core/reply-context.ts +31 -0
- package/src/core/routes/config-builtin.ts +11 -0
- package/src/core/routes/console.ts +98 -16
- package/src/core/routes/dashboard.ts +93 -10
- package/src/core/routes/model.ts +26 -3
- package/src/core/routes/send.ts +1 -1
- package/src/core/routes/tasks.ts +74 -0
- package/src/core/runtime.ts +302 -21
- package/src/core/system-messages.ts +33 -0
- package/src/core/task-scheduler.ts +155 -7
- package/src/extensions/image-builder.ts +1 -1
- package/src/extensions/installer.ts +72 -17
- package/src/extensions/load-project.ts +74 -0
- package/src/extensions/loader.ts +50 -9
- package/src/host-version.ts +32 -0
- package/src/main.ts +42 -21
- package/src/preflight/checks/credential.ts +300 -0
- package/src/preflight/checks/docker.ts +149 -0
- package/src/preflight/checks/extensions.ts +90 -0
- package/src/preflight/checks/host-deps.ts +247 -0
- package/src/preflight/checks/image-contract.ts +228 -0
- package/src/preflight/checks/roundtrip.ts +424 -0
- package/src/preflight/checks/sandbox.ts +159 -0
- package/src/preflight/deps.ts +107 -0
- package/src/preflight/probe-container.ts +156 -0
- package/src/preflight/report.ts +177 -0
- package/src/preflight/run.ts +223 -0
- package/src/server.ts +55 -14
- package/src/storage/db.ts +41 -2
- package/src/storage/models-json.ts +110 -0
- package/src/text/reporter-lint.ts +167 -23
- 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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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}
|
|
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)
|
|
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
|
-
|
|
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
|
-
##
|
|
44
|
+
## Why this connection is not marked `sensitive`
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
apply.
|
|
58
|
+
## One thing that will look like a bug
|
|
58
59
|
|
|
59
|
-
**
|
|
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
|
-
|
|
|
106
|
-
|
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// (
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
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);
|