mercury-agent 0.23.2 → 0.24.1
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 +1 -1
- package/container/Dockerfile.base +1 -1
- package/container/agent-package.json +1 -1
- package/docs/configuration.md +60 -0
- package/docs/container-lifecycle.md +27 -15
- package/docs/extensions.md +36 -0
- package/docs/goals/rehearsal-bench/roadmap.md +1 -1
- package/docs/goals/release-gate/roadmap.md +8 -8
- package/docs/goals/whatsapp-bot-hardening/open-threads.md +18 -0
- package/docs/goals/whatsapp-bot-hardening/roadmap.md +1 -1
- package/docs/graceful-shutdown.md +21 -5
- package/docs/pending-verification.md +195 -4
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/decisions.md +1 -0
- package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/plan.md +2 -2
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S1.md +56 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S2.md +51 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S3.md +60 -0
- package/docs/plans/archive/2026-09-15-bugs-0915/sessions/S4.md +46 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S1.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S2.md +60 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S3.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/briefs/S4.md +58 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/decisions.md +8 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/plan.md +102 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S1.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S2.md +29 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S3.md +30 -0
- package/docs/plans/archive/2026-09-22-p0-bugs-0922/sessions/S4.md +32 -0
- package/docs/plans/bench-patterns-0915/briefs/S10.md +72 -0
- package/docs/plans/bench-patterns-0915/briefs/S11.md +95 -0
- package/docs/plans/bench-patterns-0915/briefs/S12.md +87 -0
- package/docs/plans/bench-patterns-0915/briefs/S9.md +81 -0
- package/docs/plans/bench-patterns-0915/decisions.md +7 -1
- package/docs/plans/bench-patterns-0915/plan.md +92 -8
- package/docs/plans/bench-patterns-0915/sessions/S10.md +40 -0
- package/docs/plans/bench-patterns-0915/sessions/S11.md +53 -0
- package/docs/plans/bench-patterns-0915/sessions/S12.md +44 -0
- package/docs/plans/bench-patterns-0915/sessions/S4.md +29 -0
- package/docs/plans/bench-patterns-0915/sessions/S5.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S6.md +46 -0
- package/docs/plans/bench-patterns-0915/sessions/S7.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S8.md +30 -0
- package/docs/plans/bench-patterns-0915/sessions/S9.md +2 -0
- package/docs/plans/flat-0916/briefs/S1.md +55 -0
- package/docs/plans/flat-0916/briefs/S2.md +55 -0
- package/docs/plans/flat-0916/decisions.md +8 -0
- package/docs/plans/flat-0916/plan.md +88 -0
- package/docs/plans/p0-plan-0922/briefs/S1.md +60 -0
- package/docs/plans/p0-plan-0922/briefs/S2.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S3.md +58 -0
- package/docs/plans/p0-plan-0922/briefs/S4.md +54 -0
- package/docs/plans/p0-plan-0922/briefs/S5.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S6.md +77 -0
- package/docs/plans/p0-plan-0922/briefs/S7.md +79 -0
- package/docs/plans/p0-plan-0922/briefs/S8.md +69 -0
- package/docs/plans/p0-plan-0922/decisions.md +9 -0
- package/docs/plans/p0-plan-0922/plan.md +215 -0
- package/docs/plans/p0-plan-0922/sessions/S1.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S2.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S3.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S4.md +22 -0
- package/docs/plans/p0-plan-0922/sessions/S5.md +44 -0
- package/docs/plans/p0-plan-0922/sessions/S6.md +60 -0
- package/docs/plans/p0-plan-0922/sessions/S7.md +69 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S1.md +63 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S2.md +62 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S3.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S4.md +61 -0
- package/docs/plans/sensitive-bugs-0922/briefs/S5.md +62 -0
- package/docs/plans/sensitive-bugs-0922/decisions.md +9 -0
- package/docs/plans/sensitive-bugs-0922/plan.md +147 -0
- package/docs/release-gate.md +1 -1
- package/docs/smoke-fixtures.md +9 -0
- package/examples/extensions/feed-watch/fixtures.ts +210 -0
- package/examples/extensions/feed-watch/index.ts +140 -9
- package/examples/extensions/office-365-mail/README.md +132 -0
- package/examples/extensions/office-365-mail/index.ts +170 -0
- package/examples/extensions/office-365-mail/lib/graph.ts +1031 -0
- package/examples/extensions/office-365-mail/scripts/login.ts +252 -0
- package/examples/extensions/office-365-mail/skill/SKILL.md +94 -0
- package/examples/profiles/football-reporter/AGENTS.md +15 -2
- package/examples/profiles/football-reporter/config.yaml +5 -1
- package/examples/profiles/tel-aviv-flat-search/AGENTS.md +5 -1
- package/package.json +16 -12
- package/resources/pi-extensions/subagent/index.ts +38 -42
- package/resources/pi-extensions/subagent/outcome.ts +28 -0
- package/src/adapters/whatsapp-thread.ts +12 -0
- package/src/agent/container-entry.ts +40 -7
- package/src/agent/container-env.ts +26 -0
- package/src/agent/container-error.ts +28 -0
- package/src/agent/container-runner.ts +50 -14
- package/src/audit/ledger-queries.ts +35 -3
- package/src/bridges/slack.ts +4 -1
- package/src/bridges/teams.ts +55 -15
- package/src/bridges/telegram.ts +3 -1
- package/src/cli/mercury.ts +46 -0
- package/src/cli/mrctl-http.ts +25 -0
- package/src/cli/mrctl.ts +13 -14
- package/src/config.ts +70 -34
- package/src/core/api.ts +51 -29
- package/src/core/boot-reconcile.ts +168 -0
- package/src/core/chat-run-journal.ts +383 -0
- package/src/core/debounce.ts +97 -1
- package/src/core/handler.ts +292 -44
- package/src/core/instance-lock.ts +159 -0
- package/src/core/operator-alerts.ts +80 -10
- package/src/core/pending-ingress.ts +298 -0
- package/src/core/routes/dashboard.ts +28 -6
- package/src/core/routes/media.ts +22 -0
- package/src/core/routes/shadow.ts +28 -3
- package/src/core/runtime.ts +139 -4
- package/src/core/simulate-ingress.ts +5 -2
- package/src/core/space-queue.ts +38 -9
- package/src/core/storage-cleanup.ts +16 -8
- package/src/core/system-messages.ts +50 -0
- package/src/core/task-scheduler.ts +150 -14
- package/src/dashboard/index.html +2 -0
- package/src/main.ts +121 -2
- package/src/ops/shadow-service.ts +113 -1
- package/src/ops/shadow-snapshot.ts +126 -0
- package/src/preflight/checks/roundtrip.ts +50 -39
- package/src/smoke/cli.ts +24 -4
- package/src/storage/db.ts +621 -90
- package/src/types.ts +20 -3
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S1.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S2.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S3.md +0 -0
- /package/docs/plans/{bugs-0915 → archive/2026-09-15-bugs-0915}/briefs/S4.md +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow fixtures — a chosen feed body served in place of the network.
|
|
3
|
+
*
|
|
4
|
+
* A feed-watch change could be checked only by waiting for the world to
|
|
5
|
+
* publish the right headline. In a **shadow** (a rehearsal instance, never the
|
|
6
|
+
* live bot) an operator can drop a file named after a target's key into
|
|
7
|
+
* `<dataDir>/fixtures/feeds/`, and the next tick reads that file instead of the
|
|
8
|
+
* target's URL. Rehearsal-bench story M1.7, `docs/archive/2026-09-24-shadow-feed-fixtures.md`.
|
|
9
|
+
*
|
|
10
|
+
* **The fixture replaces only the transport.** What this module hands back is a
|
|
11
|
+
* `FetchImpl`; the kind's parser, the redirect policy, the byte and item caps,
|
|
12
|
+
* `HTTP <status>` on a non-ok answer, item ids, matching, `seen` and the verify
|
|
13
|
+
* task all stay the real code in `feeds.ts` / `watch.ts`. A fixture that
|
|
14
|
+
* exercised a parallel path would prove nothing about the live one.
|
|
15
|
+
*
|
|
16
|
+
* No logger and no `ctx` here — pure functions plus one async reader, like
|
|
17
|
+
* `feeds.ts`. `index.ts` owns the wiring and every log line.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomInt } from "node:crypto";
|
|
21
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
import type { FetchImpl } from "./feeds.js";
|
|
24
|
+
|
|
25
|
+
/** `<dataDir>/fixtures/feeds` — the one place a shadow looks. */
|
|
26
|
+
export const FIXTURE_SUBDIR = ["fixtures", "feeds"] as const;
|
|
27
|
+
|
|
28
|
+
/** A fixture whose whole content (trimmed) is this fails its source as HTTP 503. */
|
|
29
|
+
export const DEAD_MARKER = "dead";
|
|
30
|
+
|
|
31
|
+
/** The status a `dead` fixture answers with — the throw a real 503 makes. */
|
|
32
|
+
const DEAD_STATUS = 503;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The fixture directory, or `null` when fixtures are off.
|
|
36
|
+
*
|
|
37
|
+
* **Absence denies.** Only a literal `shadow === true` turns this on — a
|
|
38
|
+
* missing, false or non-boolean value (`"true"` from a hand-built config) is
|
|
39
|
+
* off — and a shadow without a non-empty `dataDir` has nowhere to look. On
|
|
40
|
+
* the live bot this is one strict equality and the directory is never listed
|
|
41
|
+
* or read.
|
|
42
|
+
*
|
|
43
|
+
* `resolve()` gives the path `loadConfig`'s `resolveProjectPath` gave the
|
|
44
|
+
* shadow data-dir guard (same cwd; `tests/feed-watch-fixtures.test.ts` pins
|
|
45
|
+
* the equivalence). The extension cannot import that function: feed-watch
|
|
46
|
+
* imports nothing from `src/`, and its installed snapshot could not resolve
|
|
47
|
+
* such an import. Never `~/mercury-shadow/current`, which `up` can move, and
|
|
48
|
+
* never `os.homedir()` or an env var.
|
|
49
|
+
*/
|
|
50
|
+
export function shadowFixtureDir(config: {
|
|
51
|
+
shadow?: unknown;
|
|
52
|
+
dataDir?: unknown;
|
|
53
|
+
}): string | null {
|
|
54
|
+
if (config.shadow !== true) return null;
|
|
55
|
+
const dataDir = config.dataDir;
|
|
56
|
+
if (typeof dataDir !== "string" || dataDir.length === 0) return null;
|
|
57
|
+
return join(resolve(dataDir), ...FIXTURE_SUBDIR);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The file name a target key is served from: `encodeURIComponent`, no
|
|
62
|
+
* extension. It round-trips exactly and never contains `/`.
|
|
63
|
+
*/
|
|
64
|
+
export function fixtureFileName(targetKey: string): string {
|
|
65
|
+
return encodeURIComponent(targetKey);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The target key a file name encodes, or `null` for a dotfile (`.gitkeep`, an
|
|
70
|
+
* editor swap file — ignored) or a name `decodeURIComponent` rejects
|
|
71
|
+
* (reported as unknown by the caller).
|
|
72
|
+
*/
|
|
73
|
+
export function fixtureKeyOf(fileName: string): string | null {
|
|
74
|
+
if (fileName.startsWith(".")) return null;
|
|
75
|
+
try {
|
|
76
|
+
return decodeURIComponent(fileName);
|
|
77
|
+
} catch {
|
|
78
|
+
// A malformed escape is not a key; the caller warns about it as an
|
|
79
|
+
// unknown file, which is where the operator learns the name is wrong.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Nine decimal digits, drawn once per process.
|
|
86
|
+
*
|
|
87
|
+
* Digits only: a Telegram fixture puts it in `data-post="<channel>/{{nonce}}"`,
|
|
88
|
+
* and `TG_POST` in `feeds.ts` accepts `\d{1,12}` and nothing else.
|
|
89
|
+
*/
|
|
90
|
+
export function newNonce(): string {
|
|
91
|
+
return String(randomInt(100_000_000, 1_000_000_000));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Substitute the template tokens, every occurrence. Anything else in double
|
|
96
|
+
* braces is left as written.
|
|
97
|
+
*
|
|
98
|
+
* - `{{nonce}}` — the per-process nonce (item ids: `<guid>`, `<id>`, `data-post`)
|
|
99
|
+
* - `{{now}}` — RFC 822 (`<pubDate>`)
|
|
100
|
+
* - `{{now_iso}}` — ISO 8601 (Atom `<updated>`, Telegram `datetime`)
|
|
101
|
+
*/
|
|
102
|
+
export function renderFixture(
|
|
103
|
+
body: string,
|
|
104
|
+
t: { nowMs: number; nonce: string },
|
|
105
|
+
): string {
|
|
106
|
+
const now = new Date(t.nowMs);
|
|
107
|
+
return body
|
|
108
|
+
.replaceAll("{{nonce}}", t.nonce)
|
|
109
|
+
.replaceAll("{{now_iso}}", now.toISOString())
|
|
110
|
+
.replaceAll("{{now}}", now.toUTCString());
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type FixtureHit =
|
|
114
|
+
| { kind: "body"; body: string; file: string }
|
|
115
|
+
| { kind: "dead"; file: string };
|
|
116
|
+
|
|
117
|
+
function isEnoent(err: unknown): boolean {
|
|
118
|
+
return (
|
|
119
|
+
typeof err === "object" &&
|
|
120
|
+
err !== null &&
|
|
121
|
+
(err as { code?: unknown }).code === "ENOENT"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function errorCode(err: unknown): string {
|
|
126
|
+
const code =
|
|
127
|
+
typeof err === "object" && err !== null
|
|
128
|
+
? (err as { code?: unknown }).code
|
|
129
|
+
: undefined;
|
|
130
|
+
if (typeof code === "string" && code.length > 0) return code;
|
|
131
|
+
return err instanceof Error ? err.message : String(err);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The reader over one fixture directory.
|
|
136
|
+
*
|
|
137
|
+
* `node:fs/promises` only: no sync read on the host thread. A fixture is an
|
|
138
|
+
* operator-written local file in a directory only a shadow reads, so it is not
|
|
139
|
+
* read in bounded chunks; `fetchFeed`'s byte cap still applies to what it
|
|
140
|
+
* serves.
|
|
141
|
+
*/
|
|
142
|
+
export function createFixtureSource(dir: string): {
|
|
143
|
+
lookup(targetKey: string): Promise<FixtureHit | null>;
|
|
144
|
+
list(): Promise<string[]>;
|
|
145
|
+
} {
|
|
146
|
+
return {
|
|
147
|
+
/**
|
|
148
|
+
* `null` when no file of that name exists (ENOENT) — the request goes to
|
|
149
|
+
* the network unchanged. **Any other read error rejects** with
|
|
150
|
+
* `fixture unreadable: <code>`: the source then fails visibly and backs
|
|
151
|
+
* off, and never silently turns into a network fetch the operator did not
|
|
152
|
+
* ask for.
|
|
153
|
+
*/
|
|
154
|
+
async lookup(targetKey) {
|
|
155
|
+
const file = fixtureFileName(targetKey);
|
|
156
|
+
let body: string;
|
|
157
|
+
try {
|
|
158
|
+
body = await readFile(join(dir, file), "utf8");
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (isEnoent(err)) return null;
|
|
161
|
+
throw new Error(`fixture unreadable: ${errorCode(err)}`);
|
|
162
|
+
}
|
|
163
|
+
if (body.trim() === DEAD_MARKER) return { kind: "dead", file };
|
|
164
|
+
return { kind: "body", body, file };
|
|
165
|
+
},
|
|
166
|
+
/** Fixture file names, dotfiles excluded; `[]` when the directory is absent. */
|
|
167
|
+
async list() {
|
|
168
|
+
let names: string[];
|
|
169
|
+
try {
|
|
170
|
+
names = await readdir(dir);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (isEnoent(err)) return [];
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
return names.filter((n) => !n.startsWith(".")).sort();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `FetchImpl` that answers one hit. It ignores the URL: the file was
|
|
182
|
+
* chosen by target key, not by URL, and the kind's parser and redirect policy
|
|
183
|
+
* are still chosen by `fetchForTarget` exactly as for the network.
|
|
184
|
+
*/
|
|
185
|
+
export function fixtureFetchImpl(
|
|
186
|
+
hit: FixtureHit,
|
|
187
|
+
t: { nowMs: number; nonce: string },
|
|
188
|
+
): FetchImpl {
|
|
189
|
+
if (hit.kind === "dead") {
|
|
190
|
+
return async () => ({
|
|
191
|
+
ok: false,
|
|
192
|
+
status: DEAD_STATUS,
|
|
193
|
+
text: async () => "",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const rendered = renderFixture(hit.body, t);
|
|
197
|
+
return async () => ({ ok: true, status: 200, text: async () => rendered });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Files whose decoded key is not in `knownKeys` (undecodable names included). */
|
|
201
|
+
export function unknownFixtures(
|
|
202
|
+
files: readonly string[],
|
|
203
|
+
knownKeys: ReadonlySet<string>,
|
|
204
|
+
): string[] {
|
|
205
|
+
return files.filter((f) => {
|
|
206
|
+
if (f.startsWith(".")) return false;
|
|
207
|
+
const key = fixtureKeyOf(f);
|
|
208
|
+
return key === null || !knownKeys.has(key);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
|
-
import { CONFIG_KEYS } from "./config.js";
|
|
26
|
+
import { CONFIG_KEYS, readSpaceConfig } from "./config.js";
|
|
27
27
|
import {
|
|
28
28
|
type DigestFileIO,
|
|
29
29
|
handleAfterContainer,
|
|
@@ -38,8 +38,18 @@ import {
|
|
|
38
38
|
type OnTruncate,
|
|
39
39
|
parseTelegramPreview,
|
|
40
40
|
} from "./feeds.js";
|
|
41
|
+
import {
|
|
42
|
+
createFixtureSource,
|
|
43
|
+
fixtureFetchImpl,
|
|
44
|
+
fixtureFileName,
|
|
45
|
+
fixtureKeyOf,
|
|
46
|
+
newNonce,
|
|
47
|
+
shadowFixtureDir,
|
|
48
|
+
unknownFixtures,
|
|
49
|
+
} from "./fixtures.js";
|
|
41
50
|
import type { PostedReply } from "./posted.js";
|
|
42
51
|
import {
|
|
52
|
+
expandSources,
|
|
43
53
|
type FetchTarget,
|
|
44
54
|
type PollDeps,
|
|
45
55
|
pollSpace,
|
|
@@ -59,6 +69,13 @@ export const POLL_INTERVAL_MS = 180_000;
|
|
|
59
69
|
*/
|
|
60
70
|
export const LAST_TICK_KEY = "last_tick";
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* How many `{ key, file }` pairs the unknown-fixture warning carries. A space
|
|
74
|
+
* with a gnews fan-out has one key per term, and a warn line is not the place
|
|
75
|
+
* for all of them; `more` says how many were left out.
|
|
76
|
+
*/
|
|
77
|
+
export const FIXTURE_KNOWN_KEYS_SHOWN = 40;
|
|
78
|
+
|
|
62
79
|
// Ships alongside this extension, so `import.meta.dir` is the installed
|
|
63
80
|
// extension directory — the same arrangement longview uses for its prompt.
|
|
64
81
|
const VERIFY_PROMPT_PATH = join(import.meta.dir, "prompts", "verify.md");
|
|
@@ -98,7 +115,9 @@ interface ExtensionDb {
|
|
|
98
115
|
|
|
99
116
|
interface ExtensionContext {
|
|
100
117
|
db: ExtensionDb;
|
|
101
|
-
|
|
118
|
+
// `shadow` and `dataDir` are read only by the fixture lookup, and only a
|
|
119
|
+
// literal `shadow === true` turns it on (`shadowFixtureDir`).
|
|
120
|
+
config: { defaultTimezone?: string; shadow?: boolean; dataDir?: string };
|
|
102
121
|
// Mirrors `Logger` in `src/logger.ts`: every level takes a message and one
|
|
103
122
|
// optional `extra`. An extension declares its own structural type and the
|
|
104
123
|
// loader hands it an untyped object, so nothing would catch a third argument
|
|
@@ -281,6 +300,22 @@ export default function (mercury: MercuryApi) {
|
|
|
281
300
|
*/
|
|
282
301
|
let missingReadWarned = false;
|
|
283
302
|
|
|
303
|
+
// ── Shadow fixtures ──────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Drawn once per process: every tick of one shadow renders a fixture to the
|
|
307
|
+
* same item ids, so the second tick finds the item in `seen` and opens no
|
|
308
|
+
* second run; the next `shadow up` draws a new one, so the same file is new
|
|
309
|
+
* again against a snapshot that already saw it. No state is deleted.
|
|
310
|
+
*/
|
|
311
|
+
const fixtureNonce = newNonce();
|
|
312
|
+
|
|
313
|
+
/** The `shadow fixtures active` line is said on the first tick only. */
|
|
314
|
+
let fixturesAnnounced = false;
|
|
315
|
+
|
|
316
|
+
/** Unknown fixture file names already warned about — once per name per process. */
|
|
317
|
+
const fixtureWarned = new Set<string>();
|
|
318
|
+
|
|
284
319
|
/**
|
|
285
320
|
* The one place the host's `listRepliesSince` is reached. Both consumers —
|
|
286
321
|
* the poller's link drop and the `<recent_posts>` hook — go through it, so
|
|
@@ -304,13 +339,16 @@ export default function (mercury: MercuryApi) {
|
|
|
304
339
|
};
|
|
305
340
|
}
|
|
306
341
|
|
|
307
|
-
function buildDeps(
|
|
342
|
+
function buildDeps(
|
|
343
|
+
ctx: ExtensionContext,
|
|
344
|
+
fixtures: ReturnType<typeof createFixtureSource> | null,
|
|
345
|
+
): Omit<PollDeps, "log"> & {
|
|
308
346
|
log: WatchLogger;
|
|
309
347
|
} {
|
|
310
348
|
return {
|
|
311
349
|
getConfig: (spaceId, key) => ctx.getConfig(spaceId, key),
|
|
312
350
|
store,
|
|
313
|
-
fetchFeed: (target: FetchTarget): Promise<FeedItem[]> => {
|
|
351
|
+
fetchFeed: async (target: FetchTarget): Promise<FeedItem[]> => {
|
|
314
352
|
// Hoisted now that there are two kinds — M1.2 left it inline in the
|
|
315
353
|
// `xml` case so that case would diff against the pre-seam closure as
|
|
316
354
|
// whitespace only. That debt is paid by the second caller, not by a
|
|
@@ -325,10 +363,23 @@ export default function (mercury: MercuryApi) {
|
|
|
325
363
|
...detail,
|
|
326
364
|
});
|
|
327
365
|
};
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
366
|
+
let fetchImpl = globalThis.fetch as unknown as FetchImpl;
|
|
367
|
+
// Shadow only. A lookup that rejects (anything but ENOENT) rejects
|
|
368
|
+
// this fetch, so the source fails visibly as `fixture unreadable`
|
|
369
|
+
// rather than quietly reading the network instead.
|
|
370
|
+
const hit = fixtures ? await fixtures.lookup(target.key) : null;
|
|
371
|
+
if (hit) {
|
|
372
|
+
ctx.log.info("feed-watch: serving fixture", {
|
|
373
|
+
source: target.key,
|
|
374
|
+
file: hit.file,
|
|
375
|
+
dead: hit.kind === "dead",
|
|
376
|
+
});
|
|
377
|
+
fetchImpl = fixtureFetchImpl(hit, {
|
|
378
|
+
nowMs: Date.now(),
|
|
379
|
+
nonce: fixtureNonce,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return fetchForTarget(target, { fetchImpl, onTruncate });
|
|
332
383
|
},
|
|
333
384
|
createTask: (spaceId, prompt, runAs) =>
|
|
334
385
|
ctx.db.createTask(
|
|
@@ -355,6 +406,71 @@ export default function (mercury: MercuryApi) {
|
|
|
355
406
|
};
|
|
356
407
|
}
|
|
357
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The two fixture lines a shadow tick owes its operator: the directory on
|
|
411
|
+
* the first tick (even at `files: 0` — it proves the lookup is on and names
|
|
412
|
+
* the path to copy into; `setup` has no `ctx`, so the first tick is the
|
|
413
|
+
* earliest `ctx.config` can be read), and each file name that matches no
|
|
414
|
+
* target of any enabled space, once per name per process.
|
|
415
|
+
*
|
|
416
|
+
* Known keys come from the functions `pollSpace` itself uses —
|
|
417
|
+
* `readSpaceConfig` → `expandSources` — so the warning cannot disagree with
|
|
418
|
+
* the poller about what a key is. Not from fetched targets: a target in
|
|
419
|
+
* backoff (one served `dead`, say) is not fetched and would read as unknown.
|
|
420
|
+
* `readSpaceConfig` is called without a logger because `pollSpace` has
|
|
421
|
+
* already said whatever is wrong with the config this tick.
|
|
422
|
+
*/
|
|
423
|
+
async function reportFixtures(
|
|
424
|
+
ctx: ExtensionContext,
|
|
425
|
+
dir: string,
|
|
426
|
+
fixtures: ReturnType<typeof createFixtureSource>,
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
const files = await fixtures.list();
|
|
429
|
+
if (!fixturesAnnounced) {
|
|
430
|
+
fixturesAnnounced = true;
|
|
431
|
+
ctx.log.info("feed-watch: shadow fixtures active", {
|
|
432
|
+
dir,
|
|
433
|
+
files: files.length,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (files.every((f) => fixtureWarned.has(f))) return;
|
|
437
|
+
|
|
438
|
+
const known = new Set<string>();
|
|
439
|
+
let enabledSpaces = 0;
|
|
440
|
+
for (const space of ctx.db.listSpaces()) {
|
|
441
|
+
const cfg = readSpaceConfig(
|
|
442
|
+
(spaceId, key) => ctx.getConfig(spaceId, key),
|
|
443
|
+
space.id,
|
|
444
|
+
);
|
|
445
|
+
if (!cfg.enabled) continue;
|
|
446
|
+
enabledSpaces++;
|
|
447
|
+
for (const target of expandSources(cfg)) known.add(target.key);
|
|
448
|
+
}
|
|
449
|
+
// With nothing enabled every file is "unknown", and saying so once would
|
|
450
|
+
// use up the warning before the operator enables the space it was for.
|
|
451
|
+
if (enabledSpaces === 0) return;
|
|
452
|
+
|
|
453
|
+
const unknown = unknownFixtures(files, known).filter(
|
|
454
|
+
(f) => !fixtureWarned.has(f),
|
|
455
|
+
);
|
|
456
|
+
if (unknown.length === 0) return;
|
|
457
|
+
const keys = [...known].sort();
|
|
458
|
+
const shown = keys
|
|
459
|
+
.slice(0, FIXTURE_KNOWN_KEYS_SHOWN)
|
|
460
|
+
.map((key) => ({ key, file: fixtureFileName(key) }));
|
|
461
|
+
for (const file of unknown) {
|
|
462
|
+
fixtureWarned.add(file);
|
|
463
|
+
ctx.log.warn("feed-watch: fixture matches no source", {
|
|
464
|
+
file,
|
|
465
|
+
decoded: fixtureKeyOf(file),
|
|
466
|
+
known: shown,
|
|
467
|
+
...(keys.length > shown.length
|
|
468
|
+
? { more: keys.length - shown.length }
|
|
469
|
+
: {}),
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
358
474
|
mercury.job("poll", {
|
|
359
475
|
interval: POLL_INTERVAL_MS,
|
|
360
476
|
run: async (ctx) => {
|
|
@@ -379,7 +495,9 @@ export default function (mercury: MercuryApi) {
|
|
|
379
495
|
deferred: [] as string[],
|
|
380
496
|
};
|
|
381
497
|
try {
|
|
382
|
-
const
|
|
498
|
+
const fixtureDir = shadowFixtureDir(ctx.config);
|
|
499
|
+
const fixtures = fixtureDir ? createFixtureSource(fixtureDir) : null;
|
|
500
|
+
const deps = buildDeps(ctx, fixtures);
|
|
383
501
|
for (const space of ctx.db.listSpaces()) {
|
|
384
502
|
tally.spaces++;
|
|
385
503
|
try {
|
|
@@ -405,6 +523,19 @@ export default function (mercury: MercuryApi) {
|
|
|
405
523
|
});
|
|
406
524
|
}
|
|
407
525
|
}
|
|
526
|
+
if (fixtureDir && fixtures) {
|
|
527
|
+
try {
|
|
528
|
+
await reportFixtures(ctx, fixtureDir, fixtures);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
// Counted like a space's failure, and said: a fixture directory
|
|
531
|
+
// that cannot be listed is the operator's whole rehearsal.
|
|
532
|
+
tally.errors++;
|
|
533
|
+
ctx.log.error("feed-watch: fixture directory unreadable", {
|
|
534
|
+
dir: fixtureDir,
|
|
535
|
+
error: err instanceof Error ? err.message : String(err),
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
408
539
|
} finally {
|
|
409
540
|
inFlight = false;
|
|
410
541
|
const elapsedMs = Date.now() - started;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# office-365-mail — Outlook / Microsoft 365 mail over Microsoft Graph
|
|
2
|
+
|
|
3
|
+
Read, search, triage, send and reply to a Microsoft 365 (Office 365 / Outlook)
|
|
4
|
+
mailbox from a chat. Third adopter of `mercury.capability()`: the Entra client
|
|
5
|
+
id, the refresh token and every rotated successor stay in the Mercury host
|
|
6
|
+
process and never enter an agent container.
|
|
7
|
+
|
|
8
|
+
Microsoft has switched off basic-auth IMAP/SMTP for Microsoft 365, so this
|
|
9
|
+
extension speaks Microsoft Graph over delegated OAuth2. No CLI is installed in
|
|
10
|
+
the image and there are no runtime dependencies — plain `fetch`.
|
|
11
|
+
|
|
12
|
+
## What this example shows
|
|
13
|
+
|
|
14
|
+
- `mercury.capability()` with ten verbs the agent calls via `mrctl capability office-365-mail <action> '<json>'`
|
|
15
|
+
- three `hostOnly` env claims, so no `M365_*` value reaches a container
|
|
16
|
+
- a `mercury.connection()` whose daily `healthCheck` is a forced token exchange through the same single-flight path the verbs use
|
|
17
|
+
- a credential that **rotates on every use** — see "How the refresh token is stored"
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
mercury add ./examples/extensions/office-365-mail
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No CLI is declared, so no Docker image rebuild is needed.
|
|
26
|
+
|
|
27
|
+
## One-time setup
|
|
28
|
+
|
|
29
|
+
### 1. Register a public-client app in Microsoft Entra
|
|
30
|
+
|
|
31
|
+
In the [Entra admin center](https://entra.microsoft.com) → **App registrations** → **New registration**:
|
|
32
|
+
|
|
33
|
+
- Name: anything (e.g. `Mercury mail`)
|
|
34
|
+
- Supported account types: *Accounts in this organizational directory only* is enough for one work mailbox
|
|
35
|
+
- Redirect URI: leave empty (the device-code flow needs none)
|
|
36
|
+
|
|
37
|
+
Then, on the new registration:
|
|
38
|
+
|
|
39
|
+
- **Authentication** → *Advanced settings* → **Allow public client flows: Yes** → Save. Without this the device-code login fails with `AADSTS7000218` / `invalid_client`.
|
|
40
|
+
- **API permissions** → *Add a permission* → *Microsoft Graph* → *Delegated* → add `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Some tenants require an admin to press **Grant admin consent** for these; if the login step below says consent is required, that is the button.
|
|
41
|
+
- Copy the **Application (client) ID** from *Overview*.
|
|
42
|
+
|
|
43
|
+
No client secret is created — the app is a public client and the login proves the user, not the app.
|
|
44
|
+
|
|
45
|
+
### 2. Sign in once, on the host
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bun examples/extensions/office-365-mail/scripts/login.ts --client-id <application-client-id>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
(After `mercury add`, the same script lives at `.mercury/extensions/office-365-mail/scripts/login.ts`.)
|
|
52
|
+
|
|
53
|
+
It prints a URL and a short code. Open the URL in any browser, sign in as the
|
|
54
|
+
mailbox owner, enter the code, accept the permissions. The script then prints:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
MERCURY_M365_CLIENT_ID=…
|
|
58
|
+
MERCURY_M365_TENANT=organizations
|
|
59
|
+
MERCURY_M365_REFRESH_TOKEN=…
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Add those lines to Mercury's `.env` and restart Mercury. All three are claimed
|
|
63
|
+
`hostOnly`.
|
|
64
|
+
|
|
65
|
+
`--tenant` defaults to `organizations` (any work or school account). If Microsoft
|
|
66
|
+
answers `AADSTS90133` ("device code flow is not supported under this
|
|
67
|
+
endpoint"), pass your tenant id or primary domain: `--tenant contoso.onmicrosoft.com`.
|
|
68
|
+
For a personal outlook.com mailbox use `--tenant consumers` (untested).
|
|
69
|
+
|
|
70
|
+
### 3. Verify
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
mercury chat --space main --caller "<admin-caller-id>" \
|
|
74
|
+
"run exactly: mrctl capability office-365-mail list-folders '{}'"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A non-admin caller is blocked by RBAC (`Forbidden: requires 'office-365-mail' permission`).
|
|
78
|
+
The console's Connections page shows **Office 365 Mail** as connected.
|
|
79
|
+
|
|
80
|
+
## How the refresh token is stored
|
|
81
|
+
|
|
82
|
+
Microsoft's token endpoint returns a **new refresh token on every exchange**.
|
|
83
|
+
The `.env` value is therefore only a seed:
|
|
84
|
+
|
|
85
|
+
- The newest token lives in Mercury's extension state (`state.db`), under a
|
|
86
|
+
fingerprint of the seed it descends from.
|
|
87
|
+
- An unchanged seed defers to its stored descendant. A **changed** seed (you
|
|
88
|
+
re-ran `login.ts`) wins outright and the old descendant is dropped.
|
|
89
|
+
- Removing the seed from `.env` after the first rotation does **not** break the
|
|
90
|
+
connection — the extension loads on `MERCURY_M365_CLIENT_ID`, not the seed.
|
|
91
|
+
- A failed exchange never deletes the stored token. Microsoft keeps the previous
|
|
92
|
+
token valid, so a transient outage costs nothing.
|
|
93
|
+
|
|
94
|
+
Consequence worth knowing: `state.db` now carries a live mailbox credential.
|
|
95
|
+
Treat its backups the way you treat `.env`.
|
|
96
|
+
|
|
97
|
+
## When it needs re-authentication
|
|
98
|
+
|
|
99
|
+
The daily connection-health pass does one token exchange. If Microsoft rejects
|
|
100
|
+
the token (`invalid_grant` — password changed, sessions revoked, 90 days of
|
|
101
|
+
inactivity, conditional-access policy), the operator alert names the fix and
|
|
102
|
+
the Connections page shows **needs-reauth**. Re-run step 2, replace the three
|
|
103
|
+
`.env` lines, restart.
|
|
104
|
+
|
|
105
|
+
## Verbs
|
|
106
|
+
|
|
107
|
+
| Action | Body | Returns |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `list-folders` | `{}` | `{ folders: [{ id, name, total, unread }] }` |
|
|
110
|
+
| `list-inbox` | `{ limit?, folder? }` | `{ folder, total, messages: [...] }` |
|
|
111
|
+
| `search` | `{ query, limit?, folder? }` | `{ query, folder, total, messages: [...] }` — `query` is KQL (`from:ann subject:invoice`) |
|
|
112
|
+
| `read` | `{ id }` | message with text `body`, `cc`, `attachments: [{ name, size }]`, `conversationId` |
|
|
113
|
+
| `move` | `{ id, destination }` | `{ ok, id: <new id>, to }` — the id changes |
|
|
114
|
+
| `delete` | `{ id }` | `{ ok, id: <new id>, folder: "deleteditems" }` — a move, recoverable |
|
|
115
|
+
| `mark-read` / `mark-unread` | `{ id }` | `{ ok, id, marked }` |
|
|
116
|
+
| `send` | `{ to, subject, body, cc? }` | `{ ok, to, subject }` |
|
|
117
|
+
| `reply` | `{ id, body }` | `{ ok, id, subject }` |
|
|
118
|
+
|
|
119
|
+
Folders: the well-known names `inbox`, `drafts`, `sentitems`, `deleteditems`,
|
|
120
|
+
`junkemail`, `archive` work in any mailbox language; anything else is matched by
|
|
121
|
+
display name. `limit` defaults to 20 and is capped at 100.
|
|
122
|
+
|
|
123
|
+
Every failure is returned as JSON with Microsoft's own words, never thrown. A
|
|
124
|
+
`401` carries `"reauth": true`.
|
|
125
|
+
|
|
126
|
+
## Not implemented
|
|
127
|
+
|
|
128
|
+
- Calendar, contacts, OneDrive — different scopes; a separate extension.
|
|
129
|
+
- Attachments beyond names and sizes (no upload on send, no download on read).
|
|
130
|
+
- Application (client-credentials) permissions and shared mailboxes — one delegated mailbox per host.
|
|
131
|
+
- A dashboard "Connect Microsoft" button — the console has no OAuth redirect flow; the host-side login is the operator step, as with `gws auth login`.
|
|
132
|
+
- Nested folders — `list-folders` lists the root level only, and a subfolder's display name is not resolved by `list-inbox`, `search` or `move`. The well-known names and root-level folders cover the common case.
|