mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
ee97c443b7
## Why The tools-server shows an update reminder (a `note` appended to every tool response) once a newer `@swmansion/argent` lands on npm. Two pain points: 1. **No way to turn it off.** Suppression is in-memory only (`dismiss-update`, 30-min auto), so it comes back on every server restart. 2. **It nags about versions you can't install yet.** Registries with a **minimum-release-age** (package-cooldown) security policy refuse to install a version until it has been public for N days. Until then the reminder repeats every day for the whole cooldown window with nothing the user can do about it — exactly the "reminded for 5+ days" situation. ## What's in this PR **1. A config option to disable the reminder** - New env var **`ARGENT_DISABLE_UPDATE_NOTIFICATIONS=1`** (`"1"` or `"true"`). - When set, `startUpdateChecker()` is a no-op: **no registry request** is made and **no note** is ever attached. - The opt-out is also surfaced inside the reminder text itself, so the agent can tell the user how to silence it permanently. **2. Minimum-release-age awareness** (answers "detect the policy and check the update is actually installable first") - The checker now fetches the **npm packument** (`registry.npmjs.org/@swmansion/argent`) instead of `/latest`, because only the packument carries the `time` map of version → publish timestamp. - It detects the machine's policy and only reminds once the latest version has **aged past** it. Notification is gated on a new **`updateInstallable`** flag (newer **and** old enough) rather than `updateAvailable`. - `updateAvailable` keeps its pure version-comparison meaning; with no policy, `updateInstallable === updateAvailable` (no behavior change for users without a policy). ### Is detecting the user's min-age policy possible? Yes — partially. Each package manager stores it differently, and the tools-server has no reliable signal for *which* PM installed argent, so we probe every PM present and take the **most restrictive** (largest) value. Erring toward over-waiting is intentional: a slightly late reminder beats nagging about a forbidden version. | PM | Config key | Unit | Detect via | Min PM version | |----|-----------|------|-----------|----------------| | npm | `min-release-age` | days | `npm config get min-release-age` | 11.10.0 | | pnpm | `minimumReleaseAge` | minutes | `pnpm config get minimumReleaseAge` | 10.16 | | yarn (berry) | `npmMinimalAgeGate` | minutes | `yarn config get npmMinimalAgeGate` | 4.10 | | bun | `minimumReleaseAge` (bunfig.toml `[install]`) | seconds | no `config get` — not auto-probed | 1.3 | | yarn classic | — (unsupported) | — | — | — | Because bun has no `config get` for this and auto-detection is best-effort, there's also an explicit override: - **`ARGENT_MIN_RELEASE_AGE_DAYS`** — positive number of days; wins outright and skips probing (useful for bun users, locked-down CI, or pinning the value). When a policy holds an update back, a single stderr line explains the silence; no reminder reaches the agent. ## Conservative edge handling - Network/registry failure → state unchanged (never crashes the server). - Policy in effect but publish time missing/unparseable → treated as **not** installable (stay silent rather than nag). - PM probe errors / PM absent → treated as no policy (0). - Probes shell out via PATH (`exec`, 3s timeout, constant commands only — no caller input) so Windows `.cmd`/`.ps1` shims resolve. ## Tests - `test/min-release-age.test.ts` (new): unit-level parsing, override precedence, unit normalization, most-restrictive-wins, PM-absent. - `test/update-checker.test.ts`: rewritten for the packument shape; new cases for held-by-policy, aged-past-policy, unknown publish time, and the disable flag (probe mocked so tests never spawn a real PM). - `test/http-update-note.test.ts`: gate now keys off `updateInstallable`; added a "available but held by policy → no note" case. - Full tool-server suite green (**732 tests**), `tsc --noEmit` and `typecheck:tests` clean. ## Follow-up (not in this PR) The env-var reference lives in the `argent-private` submodule (`docs/environment-variables.md`), which is a separate repo — needs a companion PR. Suggested rows for the tools-server table: ``` | `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` | `"1"`/`"true"` or unset | disabled | When truthy, disables the update reminder entirely (no registry check, no note). | | `ARGENT_MIN_RELEASE_AGE_DAYS` | number | _(auto-detect)_ | Minimum age (days) a newer version must reach before it is advertised. Overrides auto-detection of the npm/pnpm/yarn minimum-release-age policy. | ``` ## Open questions for review - **Most-restrictive-across-PMs** vs. detecting a single PM — is "max wins" the behavior we want, or should it follow `npm_config_user_agent`? - Should an update held by policy stay fully silent (current), or emit a softened "available, installable in ~N days" note? --------- Co-authored-by: Cursor <cursoragent@cursor.com>
318 lines
9.6 KiB
TypeScript
318 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import supertest from "supertest";
|
|
import { createHttpApp, type HttpAppHandle } from "../src/http";
|
|
import type { UpdateState } from "../src/utils/update-checker";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
let suppressed = false;
|
|
|
|
/** Build a full UpdateState from partial overrides so tests stay terse. */
|
|
function mkState(overrides: Partial<UpdateState> = {}): UpdateState {
|
|
return {
|
|
updateAvailable: false,
|
|
updateInstallable: false,
|
|
installableVersion: null,
|
|
latestVersion: null,
|
|
latestPublishedAt: null,
|
|
minReleaseAgeMs: 0,
|
|
currentVersion: "1.0.0",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Mock update-checker before any imports that use it transitively.
|
|
vi.mock("../src/utils/update-checker", () => ({
|
|
getUpdateState: vi.fn((): UpdateState => mkState()),
|
|
isUpdateNoteSuppressed: vi.fn(() => suppressed),
|
|
suppressUpdateNote: vi.fn(),
|
|
}));
|
|
|
|
import { getUpdateState, suppressUpdateNote } from "../src/utils/update-checker";
|
|
|
|
const mockGetUpdateState = vi.mocked(getUpdateState);
|
|
const mockSuppressUpdateNote = vi.mocked(suppressUpdateNote);
|
|
|
|
function stubRegistry(toolResult: unknown = { ok: true }): Registry {
|
|
return {
|
|
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: ["test-tool"] })),
|
|
getTool: vi.fn((name: string) => {
|
|
if (name === "test-tool") {
|
|
return {
|
|
id: "test-tool",
|
|
description: "A stub tool for testing",
|
|
inputSchema: { type: "object", properties: {} },
|
|
services: () => ({}),
|
|
execute: async () => toolResult,
|
|
};
|
|
}
|
|
return undefined;
|
|
}),
|
|
invokeTool: vi.fn(async (_name: string, _params: unknown) => toolResult),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
describe("HTTP update note injection", () => {
|
|
let handle: HttpAppHandle;
|
|
const request = supertest;
|
|
|
|
beforeEach(async () => {
|
|
suppressed = false;
|
|
mockGetUpdateState.mockReturnValue(mkState());
|
|
mockSuppressUpdateNote.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.dispose();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("does NOT include a note when update is not available", async () => {
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body).toHaveProperty("data");
|
|
expect(res.body).not.toHaveProperty("note");
|
|
});
|
|
|
|
it("includes a note when an installable update is available", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body).toHaveProperty("data");
|
|
expect(res.body).toHaveProperty("note");
|
|
expect(typeof res.body.note).toBe("string");
|
|
expect(res.body.note.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("does NOT include a note when an update is available but held by the release-age policy", async () => {
|
|
// Newer version exists, but it has not aged past the machine's policy yet,
|
|
// so it is not installable — the reminder must stay silent.
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: false,
|
|
latestVersion: "1.2.3",
|
|
minReleaseAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body).toHaveProperty("data");
|
|
expect(res.body).not.toHaveProperty("note");
|
|
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("note contains the current -> latest version string", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body.note).toContain("1.0.0 -> 1.2.3");
|
|
});
|
|
|
|
it("note mentions `npx @swmansion/argent update`", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body.note).toContain("npx @swmansion/argent update");
|
|
});
|
|
|
|
it("note mentions `update-argent` tool", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body.note).toContain("update-argent");
|
|
});
|
|
|
|
it("note mentions `dismiss-update` tool", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body.note).toContain("dismiss-update");
|
|
});
|
|
|
|
it("note instructs agent to persist the update info", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body.note).toContain("Save a note");
|
|
});
|
|
|
|
it("does NOT include a note on 500 error responses", async () => {
|
|
const errorRegistry = {
|
|
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: ["test-tool"] })),
|
|
getTool: vi.fn(() => ({
|
|
id: "test-tool",
|
|
description: "A stub tool",
|
|
inputSchema: {},
|
|
services: () => ({}),
|
|
execute: async () => {
|
|
throw new Error("tool blew up");
|
|
},
|
|
})),
|
|
invokeTool: vi.fn(async () => {
|
|
throw new Error("tool blew up");
|
|
}),
|
|
} as unknown as Registry;
|
|
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(errorRegistry);
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(500);
|
|
|
|
expect(res.body).toHaveProperty("error");
|
|
expect(res.body).not.toHaveProperty("note");
|
|
});
|
|
|
|
it("does NOT include a note on 404 (unknown tool) responses", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/nonexistent-tool").send({}).expect(404);
|
|
|
|
expect(res.body).toHaveProperty("error");
|
|
expect(res.body).not.toHaveProperty("note");
|
|
});
|
|
|
|
it("note says 'unknown' when installableVersion is null but the update is installable", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({ updateAvailable: true, updateInstallable: true, installableVersion: null })
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body).toHaveProperty("note");
|
|
expect(res.body.note).toContain("unknown");
|
|
expect(res.body.note).not.toContain("null");
|
|
});
|
|
|
|
// ── Suppression behavior ──────────────────────────────────────────
|
|
|
|
it("calls suppressUpdateNote after delivering a note", async () => {
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(mockSuppressUpdateNote).toHaveBeenCalledOnce();
|
|
expect(mockSuppressUpdateNote).toHaveBeenCalledWith(30 * 60 * 1000);
|
|
});
|
|
|
|
it("does NOT include a note when suppressed", async () => {
|
|
suppressed = true;
|
|
mockGetUpdateState.mockReturnValue(
|
|
mkState({
|
|
updateAvailable: true,
|
|
updateInstallable: true,
|
|
latestVersion: "1.2.3",
|
|
installableVersion: "1.2.3",
|
|
})
|
|
);
|
|
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(res.body).toHaveProperty("data");
|
|
expect(res.body).not.toHaveProperty("note");
|
|
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT call suppressUpdateNote when no update is available", async () => {
|
|
handle = createHttpApp(stubRegistry());
|
|
|
|
await request(handle.app).post("/tools/test-tool").send({}).expect(200);
|
|
|
|
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
|
|
});
|
|
});
|