Skip to content
Merged
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
12 changes: 12 additions & 0 deletions src/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,16 @@ Entry point: [index.ts](index.ts). Read the root instruction route and the under

Skill list/show/enable/disable accept `--workspace <dir>` for cloud project scope; `skill project on|off --workspace <dir>` controls all project sources. Without the flag enable/disable remain global. See [cloud skill controls](../skills/docs/cloud-workspace.md).

`config get --with-sha256` prints `{"sha256": "<hex>", "config": {...}}`: the normalised config and the lowercase sha256 of the raw `config.json` bytes it was parsed from, from one read (the empty-string hash when the file is missing). `config set - --expect-sha256 <hex>` writes the stdin payload only while the file still hashes to `<hex>`; otherwise it writes nothing, prints exactly `config changed since it was read` to stderr and exits 3. Without the flag `set -` is unchanged. This is the compare-and-swap the desktop uses since it shares the terminal agent's state dir (ATO-416); see [config-sha256.ts](config-sha256.ts).

## Desktop state merge

`import atomic-desktop --source <dir> [--json]` merges the desktop app's former state dir (`~/.atomic-agent-desktop`) into the current one (`ATOMIC_AGENT_STATE_DIR`, default `~/.atomic-agent`) when the desktop moves onto the shared dir (ATO-416). The desktop runs it once and moves the plain files (config.json, .env, skills, traces, models) itself; this command handles only the databases:

- sessions and tasks keep their ids and are inserted only where the id is absent, so the target wins. A live turn arrives ended as interrupted, a running task arrives pending, and neither carries an owner mark.
- memory notes are added through `MemoryStore` unless the same text is already stored; profile facts through `ProfileStore` unless the target has any row for the key.
- lessons, procedures, votes, note links and embeddings are not copied; the embedding backfill or `memory reindex` rebuilds vectors.

Each source database is read from a private snapshot (a byte copy after a clean close, otherwise SQLite's online backup over a read-only connection), so the source is never written and nothing is deleted. Writes are short transactions that wait on the target's locks, so a TUI or `serve` may keep the target open. A re-run imports nothing new. `--json` prints one object, `{ok, sessions:{imported,skipped}, tasks, notes, profileFacts, warnings, error?}`; exit 0 only when every part imported. Implementation: [atomic-desktop importer](../import/atomic-desktop/atomic-desktop-importer.ts).

Checks: `npx vitest run src/cli` when tests are present; `npm run lint`; `npm run docs:check`.
192 changes: 191 additions & 1 deletion src/cli/config-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
Expand Down Expand Up @@ -208,6 +209,195 @@ describe("configCommand", () => {
});
});

// ATO-416: the desktop and the terminal agent share one state dir and
// both write config.json; the desktop reads with a hash and writes only
// while the file still has it.
describe("compare-and-swap (get --with-sha256 / set - --expect-sha256)", () => {
const stdinOf = (...chunks: string[]) => Readable.from(chunks);
const configPath = () => join(stateDir, "config.json");
const sha256 = (bytes: Buffer | string) =>
createHash("sha256").update(bytes).digest("hex");
const payload = (url: string) =>
JSON.stringify({ version: USER_CONFIG_VERSION, localModels: { url } });

async function readWithHash(): Promise<{ sha256: string; config: Record<string, unknown> }> {
stdout = "";
expect(await configCommand(["get", "--with-sha256"])).toBe(0);
const out = JSON.parse(stdout);
stdout = "";
return out;
}

it("get --with-sha256 prints the config and the hash of the raw file bytes", async () => {
const { sha256: hash, config } = await readWithHash();
expect(hash).toBe(sha256(readFileSync(configPath())));
expect(hash).toMatch(/^[0-9a-f]{64}$/);
expect(await configCommand(["get"])).toBe(0);
expect(config).toEqual(JSON.parse(stdout));
});

it("get --with-sha256 takes no key", async () => {
expect(await configCommand(["get", "agent.maxSteps", "--with-sha256"])).toBe(1);
expect(stderr).toContain("--with-sha256 prints the whole file");
expect(stdout).toBe("");
});

it("writes when the file still has the expected hash", async () => {
const { sha256: hash } = await readWithHash();
const code = await configCommand(
["set", "-", "--expect-sha256", hash],
stdinOf(payload("http://cas:1")),
);
expect(code).toBe(0);
expect(JSON.parse(readFileSync(configPath(), "utf8")).localModels.url).toBe(
"http://cas:1",
);
// The write changed the bytes: the old hash is spent.
const again = await configCommand(
["set", "-", "--expect-sha256", hash],
stdinOf(payload("http://cas:2")),
);
expect(again).toBe(3);
});

it("refuses with exit 3 and writes nothing when another client wrote in between", async () => {
const { sha256: hash } = await readWithHash();
// The other client (the TUI, say) changes one key after our read.
expect(await configCommand(["set", "agent.maxSteps", "33"])).toBe(0);
const before = readFileSync(configPath(), "utf8");
stdout = "";
stderr = "";
const code = await configCommand(
["set", "-", "--expect-sha256", hash],
stdinOf(payload("http://stale:1")),
);
expect(code).toBe(3);
// The exact line clients match on (config loading may log above it).
expect(stderr).toMatch(/(^|\n)config changed since it was read\n$/);
expect(stdout).toBe("");
expect(readFileSync(configPath(), "utf8")).toBe(before);
});

it("any byte change counts, even one that parses the same", async () => {
const { sha256: hash } = await readWithHash();
writeFileSync(configPath(), `${readFileSync(configPath(), "utf8")}\n`);
const code = await configCommand(
["set", "-", "--expect-sha256", hash],
stdinOf(payload("http://x:3")),
);
expect(code).toBe(3);
});

it("accepts the =value spelling, uppercase hex and the flag before the dash", async () => {
const first = await readWithHash();
expect(
await configCommand(
["set", "-", `--expect-sha256=${first.sha256.toUpperCase()}`],
stdinOf(payload("http://eq:1")),
),
).toBe(0);
const second = await readWithHash();
expect(
await configCommand(
["set", "--expect-sha256", second.sha256, "-"],
stdinOf(payload("http://eq:2")),
),
).toBe(0);
expect(JSON.parse(readFileSync(configPath(), "utf8")).localModels.url).toBe(
"http://eq:2",
);
});

it("a missing file matches the hash of the empty string", async () => {
await readWithHash();
unlinkSync(configPath());
const code = await configCommand(
["set", "-", "--expect-sha256", sha256("")],
stdinOf(payload("http://fresh:1")),
);
expect(code).toBe(0);
expect(JSON.parse(readFileSync(configPath(), "utf8")).localModels.url).toBe(
"http://fresh:1",
);
});

it.each<[string[], string]>([
[["set", "-", "--expect-sha256"], "64-character hex"],
[["set", "-", "--expect-sha256", "abc"], "64-character hex"],
[["set", "-", "--expect-sha256", "g".repeat(64)], "64-character hex"],
[["set", "-", "--expect-sha256", "a".repeat(64), "--expect-sha256", "a".repeat(64)], "given twice"],
[["set", "agent.maxSteps", "40", "--expect-sha256", "a".repeat(64)], "applies only to `config set -`"],
[["set", "--expect-sha256", "a".repeat(64), payload("http://argv:1")], "applies only to `config set -`"],
])("rejects %j without writing", async (args, message) => {
await readWithHash();
const before = readFileSync(configPath(), "utf8");
const code = await configCommand(args, stdinOf(payload("http://never:1")));
expect(code).toBe(1);
expect(stderr).toContain(message);
expect(readFileSync(configPath(), "utf8")).toBe(before);
});

// ATO-416: an older build (the desktop's bundled atag) saving a file a
// newer build (the TUI) already migrated.
describe("payload version vs this build and the file on disk", () => {
const NEWER = USER_CONFIG_VERSION + 5;
const seedOnDisk = (version: number) =>
writeFileSync(configPath(), JSON.stringify({ version, log: { level: "info" } }));
const versioned = (version: number, url: string) =>
JSON.stringify({ version, localModels: { url } });

it("allows a version this build does not know when the file on disk already has it", async () => {
seedOnDisk(NEWER);
const code = await configCommand(["set", "-"], stdinOf(versioned(NEWER, "http://older:1")));
expect(code).toBe(0);
const onDisk = JSON.parse(readFileSync(configPath(), "utf8"));
expect(onDisk.version).toBe(NEWER);
expect(onDisk.localModels.url).toBe("http://older:1");
});

it("allows the round trip through get --with-sha256 and the compare-and-swap", async () => {
seedOnDisk(NEWER);
const { sha256: hash, config } = await readWithHash();
expect(config.version).toBe(NEWER);
const code = await configCommand(
["set", "-", "--expect-sha256", hash],
stdinOf(JSON.stringify({ ...config, localModels: { url: "http://older:2" } })),
);
expect(code).toBe(0);
expect(JSON.parse(readFileSync(configPath(), "utf8")).version).toBe(NEWER);
});

it("still refuses a version newer than both this build and the file on disk", async () => {
seedOnDisk(NEWER);
const before = readFileSync(configPath(), "utf8");
const code = await configCommand(["set", "-"], stdinOf(versioned(NEWER + 1, "http://never:2")));
expect(code).toBe(1);
expect(stderr).toContain(`version ${NEWER + 1} is newer than this build understands (${NEWER})`);
expect(readFileSync(configPath(), "utf8")).toBe(before);
});

it("still refuses a version newer than this build when the file is current", async () => {
await readWithHash();
const before = readFileSync(configPath(), "utf8");
const code = await configCommand(["set", "-"], stdinOf(versioned(USER_CONFIG_VERSION + 1, "http://never:3")));
expect(code).toBe(1);
expect(stderr).toContain(
`version ${USER_CONFIG_VERSION + 1} is newer than this build understands (${USER_CONFIG_VERSION})`,
);
expect(readFileSync(configPath(), "utf8")).toBe(before);
});
});

it("leaves set - without the flag unconditional", async () => {
await readWithHash();
expect(await configCommand(["set", "agent.maxSteps", "33"])).toBe(0);
expect(await configCommand(["set", "-"], stdinOf(payload("http://plain:1")))).toBe(0);
expect(JSON.parse(readFileSync(configPath(), "utf8")).localModels.url).toBe(
"http://plain:1",
);
});
});

it("set without a payload prints usage and returns non-zero", async () => {
const code = await configCommand(["set"]);
expect(code).toBe(1);
Expand Down
88 changes: 76 additions & 12 deletions src/cli/config-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ import {
ensureUserConfigFileSync,
getConfig,
parseUserConfigFile,
readUserConfigSnapshotSync,
readUserConfigVersionOnDiskSync,
resetConfigCache,
USER_CONFIG_VERSION,
userConfigFileSha256Sync,
writeUserConfigFileSync,
} from "../config/index.js";
import { reportAnalyticsOptOut, resolveSurface } from "../analytics/index.js";
import { getAppVersion } from "../version.js";
import { HELP } from "./config-help.js";
import {
CONFIG_CHANGED_EXIT_CODE,
CONFIG_CHANGED_MESSAGE,
takeExpectSha256,
WITH_SHA256_FLAG,
} from "./config-sha256.js";
import {
deleteConfigPath,
findConfigLeaf,
Expand Down Expand Up @@ -69,6 +78,23 @@ export async function configCommand(
function handleGet(args: string[]): number {
const path = getConfig().paths.userConfigFile;
const file = ensureUserConfigFileSync(path);
if (args.includes(WITH_SHA256_FLAG)) {
if (args.length !== 1) {
process.stderr.write(
`config get failed: ${WITH_SHA256_FLAG} prints the whole file; it takes no key\n`,
);
return 1;
}
// ATO-416: the hash a later `set - --expect-sha256` compares against.
// Read after `ensureUserConfigFileSync` above, so a migration that
// rewrites the file happens before it is hashed, not after; hash and
// config come from one read (see `readUserConfigSnapshotSync`).
const snapshot = readUserConfigSnapshotSync(path);
process.stdout.write(
`${JSON.stringify({ sha256: snapshot.sha256, config: snapshot.config }, null, 2)}\n`,
);
return 0;
}
if (args.length === 0) {
process.stdout.write(`${JSON.stringify(file, null, 2)}\n`);
return 0;
Expand All @@ -90,14 +116,20 @@ function handleGet(args: string[]): number {
}

async function handleSet(
args: string[],
rawArgs: string[],
stdin: NodeJS.ReadableStream,
): Promise<number> {
const cas = takeExpectSha256(rawArgs);
if (!cas.ok) {
process.stderr.write(`config set failed: ${cas.error}\n`);
return 1;
}
const args = cas.args;
if (args.length === 0) {
process.stderr.write(
"usage: atomic-agent config set <key> <value>\n" +
" or: atomic-agent config set '<json>'\n" +
" or: atomic-agent config set - < config.json\n",
" or: atomic-agent config set - [--expect-sha256 <hex>] < config.json\n",
);
return 1;
}
Expand All @@ -107,7 +139,7 @@ async function handleSet(
// as long as the write runs. The desktop app writes every config
// change this way.
if (args.length === 1 && args[0] === "-") {
return setWholeFile(await readAllText(stdin));
return setWholeFile(await readAllText(stdin), cas.expected);
}
// Form discrimination. A leading `{` means the whole-file JSON payload,
// including the case where the shell split one JSON argument across
Expand Down Expand Up @@ -138,7 +170,10 @@ async function readAllText(stream: NodeJS.ReadableStream): Promise<string> {
return Buffer.concat(chunks).toString("utf8");
}

async function setWholeFile(raw: string): Promise<number> {
async function setWholeFile(
raw: string,
expectedSha256: string | null = null,
): Promise<number> {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
Expand All @@ -152,24 +187,53 @@ async function setWholeFile(raw: string): Promise<number> {
// the write path will not let a later `config set` lower it again, and
// the file would sit permanently above every future schema, skipping
// every migration.
if (
typeof (parsed as { version?: unknown } | null)?.version === "number" &&
(parsed as { version: number }).version > USER_CONFIG_VERSION
) {
process.stderr.write(
`config set failed: version ${(parsed as { version: number }).version} is newer than this build understands (${USER_CONFIG_VERSION})\n`,
//
// ATO-416: a version already on disk is not invented, though. The
// desktop's bundled atag can be older than the TUI sharing its state
// dir; once the TUI has migrated the file, `config get` reports that
// newer version and the desktop sends it back unchanged. Refusing it
// would block every desktop save, so only a version newer than BOTH
// this build and the file on disk is refused. (The write keeps the
// on-disk version anyway — see `writeUserConfigFileSync`.)
const path = getConfig().paths.userConfigFile;
const payloadVersion = (parsed as { version?: unknown } | null)?.version;
if (typeof payloadVersion === "number") {
const accepted = Math.max(
USER_CONFIG_VERSION,
readUserConfigVersionOnDiskSync(path) ?? USER_CONFIG_VERSION,
);
return 1;
if (payloadVersion > accepted) {
process.stderr.write(
`config set failed: version ${payloadVersion} is newer than this build understands (${accepted})\n`,
);
return 1;
}
}
const next = parseUserConfigFile(parsed);
// ATO-416 compare-and-swap. Checked before the opt-out report too, so
// a refused write never sends `analytics_disabled` for a change that
// did not happen, and again right before the write, after that report
// awaited the network. What is left is the gap between this hash and
// the rename inside the write: microseconds, not a read-edit-write.
if (expectedSha256 !== null && userConfigFileSha256Sync(path) !== expectedSha256) {
return refuseChangedConfig();
}
await reportIfOptingOut(next.analytics.enabled);
const path = getConfig().paths.userConfigFile;
if (expectedSha256 !== null && userConfigFileSha256Sync(path) !== expectedSha256) {
return refuseChangedConfig();
}
writeUserConfigFileSync(path, next);
resetConfigCache();
process.stdout.write(`wrote ${path}\n`);
return 0;
}

/** Nothing written; the client re-reads (`get --with-sha256`) and retries. */
function refuseChangedConfig(): number {
process.stderr.write(`${CONFIG_CHANGED_MESSAGE}\n`);
return CONFIG_CHANGED_EXIT_CODE;
}

async function setOneKey(key: string, value: string): Promise<number> {
const leaf = findConfigLeaf(key);
if (!leaf) return rejectUnknownKey("set", key);
Expand Down
8 changes: 8 additions & 0 deletions src/cli/config-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,18 @@ export const HELP =
"Subcommands:",
" get Print the whole config file as JSON",
" get <key> Print one value by dotted key",
" get --with-sha256 Print {\"sha256\": \"<hex>\", \"config\": {...}}: the whole",
" config and the sha256 of the file's raw bytes, from",
" one read (the empty-string hash when there is no file)",
" set <key> <value> Set one value, leaving the rest of the file alone",
" set '<json>' Replace the whole config file with a JSON payload",
" set - Same, with the JSON read from stdin (keeps secrets",
" such as inline API keys off the command line)",
" set - --expect-sha256 <hex>",
" Same, but only while the file still hashes to <hex>",
" (from `get --with-sha256`); otherwise nothing is",
" written, stderr says `config changed since it was",
" read` and the exit code is 3 — re-read and retry",
" unset <key> Restore one key to its default",
" list Print every key as `key = value`",
" path Print the path to the config file",
Expand Down
Loading
Loading