Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ For development, use `openclaw plugins install --link .` so OpenClaw loads this

Set `AGENTMAIL_API_KEY` in the environment that runs the OpenClaw Gateway. To enable **webhook** ingress for the channel, also set `AGENTMAIL_WEBHOOK_SECRET` (Svix-signed); without it the channel falls back to WebSocket ingress.

For a managed Gateway, put them in `~/.openclaw/.env` so the channel and agent turns inherit the
same credentials:
For a managed Gateway, put the channel secrets in `~/.openclaw/.env`:

```dotenv
AGENTMAIL_API_KEY=am_...
Expand All @@ -50,12 +49,11 @@ openclaw plugins inspect agentmail --runtime

### CLI config

> **Credentials:** the bundled CLI authenticates only with the `AGENTMAIL_API_KEY` environment
> variable, while the **channel** can also take an inline or resolved `apiKey` in
> `channels.agentmail`. Always set `AGENTMAIL_API_KEY` in the Gateway environment so both surfaces
> are configured; a channel-only inline key leaves the CLI-backed skill unavailable.
> **Credentials:** the bundled CLI does not trust credentials inherited from the invoking process.
> Configure its `apiKey` under `plugins.entries.agentmail.config` as an inline secret or SecretRef.
> The **channel** is configured separately under `channels.agentmail`.

An optional API base URL override for the bundled CLI belongs under
The CLI credential and optional API base URL override belong under
`plugins.entries.agentmail.config`:

```json5
Expand All @@ -64,6 +62,7 @@ An optional API base URL override for the bundled CLI belongs under
entries: {
agentmail: {
config: {
apiKey: { source: "env", provider: "agentmail", id: "AGENTMAIL_API_KEY" },
baseUrl: "https://api.agentmail.to/v0",
},
},
Expand All @@ -75,13 +74,15 @@ An optional API base URL override for the bundled CLI belongs under
The previous `timeoutSeconds` and `maxRetries` tool settings remain accepted so existing
configurations continue to load, but the bundled CLI does not use them.
For credential safety, command arguments cannot override `--api-key`, `--base-url`, or
`--environment`; only inherited credentials and the operator-controlled plugin setting above can
select the AgentMail identity and API endpoint. The passthrough also removes inherited
endpoint-selector and standard proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`,
`ALL_PROXY`, and `NO_PROXY`, including lowercase forms).
`--environment`; only the operator-controlled plugin setting above can select the AgentMail
identity and API endpoint. The passthrough removes inherited AgentMail credentials, custom headers,
endpoint selectors, and standard proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`,
`ALL_PROXY`, and `NO_PROXY`, including lowercase forms), then injects only the resolved configured
CLI credential.
If a command value must literally begin with `--base-url` or `--environment`, use the CLI's
`--option=value` form (for example, `--subject=--base-url-is-restricted`). The endpoint guard fails
closed for unrecognized separate-value options.
`--option=value` form (for example, `--subject=--base-url-is-restricted`). Restricted-looking
standalone tokens are rejected in every argument position, so CLI grammar changes cannot turn one
into an unvalidated override.

### Channel config

Expand Down
84 changes: 81 additions & 3 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,98 @@
"configSchema": {
"type": "object",
"properties": {
"apiKey": {
"anyOf": [
{
"type": "string"
},
{
"oneOf": [
{
"type": "object",
"properties": {
"source": {
"type": "string",
"const": "env"
},
"provider": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
},
"id": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{0,127}$"
}
},
"required": [
"source",
"provider",
"id"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"source": {
"type": "string",
"const": "file"
},
"provider": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
},
"id": {
"type": "string"
}
},
"required": [
"source",
"provider",
"id"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"source": {
"type": "string",
"const": "exec"
},
"provider": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]{0,63}$"
},
"id": {
"type": "string"
}
},
"required": [
"source",
"provider",
"id"
],
"additionalProperties": false
}
]
}
]
},
"baseUrl": {
"type": "string",
"description": "Optional AgentMail API base URL override for the bundled CLI.",
"type": "string",
"format": "uri"
},
"timeoutSeconds": {
"type": "integer",
"description": "Deprecated tool setting retained for upgrade compatibility; the bundled CLI ignores it.",
"type": "integer",
"minimum": 1,
"maximum": 300
},
"maxRetries": {
"type": "integer",
"description": "Deprecated tool setting retained for upgrade compatibility; the bundled CLI ignores it.",
"type": "integer",
"minimum": 0,
"maximum": 10
}
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"cli:prepare": "node scripts/prepare-agentmail-cli.mjs",
"plugin:build": "npm run build && npm run cli:prepare && node scripts/build-manifest.mjs",
"plugin:check": "npm run build && node scripts/build-manifest.mjs --check",
"plugin:validate": "npm run build && npm run cli:prepare && node scripts/build-manifest.mjs --check && node scripts/validate-plugin.mjs",
"prepack": "npm run cli:prepare -- --all && npm run plugin:validate",
"plugin:validate": "npm run build && npm run cli:prepare -- --all && node scripts/build-manifest.mjs --check && node scripts/validate-plugin.mjs",
"prepack": "npm run plugin:validate",
"test": "vitest run --config ./vitest.config.ts"
},
"files": [
Expand Down
41 changes: 40 additions & 1 deletion scripts/prepare-agentmail-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
renameSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
Expand All @@ -28,6 +29,8 @@ const versionPath = join(vendorRoot, "VERSION");
const preparationLock = `${vendorRoot}.prepare-lock`;
const allTargets = Object.keys(release.assets).sort();
const INCOMPLETE_LOCK_GRACE_MS = 30_000;
const PREPARATION_LOCK_LEASE_MS = 5 * 60_000;
const PREPARATION_LOCK_HEARTBEAT_MS = 30_000;

function processIsRunning(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) {
Expand Down Expand Up @@ -84,7 +87,25 @@ function acquirePreparationLock() {
);
}
}
if (processIsRunning(existing?.pid)) {
const lockAgeMs = (() => {
try {
const mtimeMs = statSync(preparationLock).mtimeMs;
const createdAt =
Number.isFinite(existing?.createdAt) && existing.createdAt >= 0
? existing.createdAt
: 0;
return Date.now() - Math.max(createdAt, mtimeMs);
} catch (error) {
if (error?.code === "ENOENT") {
return Number.POSITIVE_INFINITY;
}
throw error;
}
})();
if (
processIsRunning(existing?.pid) &&
lockAgeMs <= PREPARATION_LOCK_LEASE_MS
) {
throw new Error(
`Another AgentMail CLI preparation (pid ${existing.pid}) is already using ${preparationLock}.`,
);
Expand All @@ -105,6 +126,22 @@ function acquirePreparationLock() {
throw new Error(`Could not acquire AgentMail CLI preparation lock ${preparationLock}.`);
}

function startPreparationLockHeartbeat(token) {
return setInterval(() => {
try {
const owner = JSON.parse(readFileSync(preparationLock, "utf8"));
if (owner?.pid !== process.pid || owner?.token !== token) {
return;
}
const now = new Date();
utimesSync(preparationLock, now, now);
} catch {
// A replacement lock belongs to another process. The owner-token check in release prevents
// this process from disturbing it during cleanup.
}
}, PREPARATION_LOCK_HEARTBEAT_MS);
}

function releasePreparationLock(token) {
try {
const owner = JSON.parse(readFileSync(preparationLock, "utf8"));
Expand Down Expand Up @@ -219,6 +256,7 @@ async function prepareTarget(target, temporaryRoot, destinationRoot) {
async function main() {
mkdirSync(vendorParent, { recursive: true });
const preparationLockToken = acquirePreparationLock();
const preparationLockHeartbeat = startPreparationLockHeartbeat(preparationLockToken);

let temporaryRoot;
let stagingParent;
Expand Down Expand Up @@ -288,6 +326,7 @@ async function main() {
`Prepared AgentMail CLI ${release.version} for ${targets.length} target${targets.length === 1 ? "" : "s"}.`,
);
} finally {
clearInterval(preparationLockHeartbeat);
if (temporaryRoot) {
rmSync(temporaryRoot, { recursive: true, force: true });
}
Expand Down
6 changes: 1 addition & 5 deletions scripts/validate-plugin.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,13 @@ const expectedCliVersion = readFileSync(
cliRunner.resolveAgentMailCliVendorPath("VERSION"),
"utf8",
).trim();
const currentCliTarget = cliRunner.resolveAgentMailCliTarget().directory;
for (const [target, metadata] of Object.entries(cliRelease.assets)) {
const executable = cliRunner.resolveAgentMailCliVendorPath(
target,
metadata.executableName,
);
if (!existsSync(executable)) {
if (target === currentCliTarget) {
fail(`bundled AgentMail CLI executable is missing for ${target}`);
}
continue;
fail(`bundled AgentMail CLI executable is missing for ${target}`);
}
const executableSha256 = createHash("sha256")
.update(readFileSync(executable))
Expand Down
102 changes: 102 additions & 0 deletions src/channel/src/catch-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,38 @@ describe("AgentMail durable REST catch-up", () => {
expect(receive).toHaveBeenCalledTimes(1);
});

it("leaves the cursor retryable when a provider page has no messages array", async () => {
const store = memoryStore<AgentMailCatchUpCursor>();
const key = sha256Hex("default\ninbox_1");
const list = vi
.fn()
.mockResolvedValueOnce({ count: 1 })
.mockResolvedValueOnce({ count: 0, messages: [] });
const error = vi.fn();
const session = await createAgentMailCatchUpSession({
account,
client: { inboxes: { messages: { list } } } as never,
store,
now: () => 2_000,
log: { error },
});

await session.run({
receive: vi.fn(),
abortSignal: new AbortController().signal,
});
expect(await store.lookup(key)).toMatchObject({ established: false });
expect(error).toHaveBeenCalledWith(
"AgentMail catch-up received a malformed messages page for account default",
);

await session.run({
receive: vi.fn(),
abortSignal: new AbortController().signal,
});
expect(await store.lookup(key)).toMatchObject({ established: true });
});

it("does not establish the cursor past a failed durable admission", async () => {
const store = memoryStore<never>();
const page = {
Expand Down Expand Up @@ -788,6 +820,76 @@ describe("AgentMail durable REST catch-up", () => {
]);
});

it("preserves a paused deep-sweep continuation across a normal sweep", async () => {
const store = memoryStore<AgentMailCatchUpCursor>();
const key = sha256Hex("default\ninbox_1");
await store.register(key, {
version: 1,
baselineAtMs: 100_000,
highWaterAtMs: 800_000,
established: true,
});
let deepPageTwoPauses = true;
const list = vi.fn(
async (_inboxId: string, options: { after: Date; pageToken?: string }) => {
if (options.pageToken === "deep_page_2") {
return {
count: 1,
messages: [message({ id: "deep_old", timestamp: 200_000 })],
};
}
if (options.after.getTime() === 100_000) {
return {
count: 1,
messages: [message({ id: "deep_page_1", timestamp: 300_000 })],
nextPageToken: "deep_page_2",
};
}
return { count: 0, messages: [] };
},
);
const session = await createAgentMailCatchUpSession({
account,
client: { inboxes: { messages: { list } } } as never,
store,
now: () => 1_000_000,
});
const receive = vi.fn(async (record: AgentMailIngressRecord) => {
if (record.messageId === "deep_old" && deepPageTwoPauses) {
deepPageTwoPauses = false;
throw new AgentMailIngressCapacityError();
}
});

await session.run({
receive,
abortSignal: new AbortController().signal,
sinceBaseline: true,
});
expect((await store.lookup(key))?.checkpoint).toMatchObject({
pageToken: "deep_page_2",
sinceBaseline: true,
});

await session.run({ receive, abortSignal: new AbortController().signal });
expect((await store.lookup(key))?.checkpoint).toMatchObject({
pageToken: "deep_page_2",
sinceBaseline: true,
});

await session.run({
receive,
abortSignal: new AbortController().signal,
sinceBaseline: true,
});
expect(list).toHaveBeenLastCalledWith(
"inbox_1",
expect.objectContaining({ pageToken: "deep_page_2" }),
expect.any(Object),
);
expect((await store.lookup(key))?.checkpoint).toBeUndefined();
});

it("resumes the last completed page after a mid-page abort", async () => {
const store = memoryStore<AgentMailCatchUpCursor>();
const key = sha256Hex("default\ninbox_1");
Expand Down
Loading
Loading