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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ The full quickstart, command reference, and architecture model live at the docum
| `doctor` | Run environment, key, vault, and daemon diagnostics. |
| `daemon` | Install and manage the background auto-sync daemon. |
| `key` | Add, list, or remove recipients (revocation), or rotate the local machine key. |
| `config` | View or change vault config: agents enabled, sync behaviour, secret-handling policy. |
| `skill` | Remove a skill from the vault explicitly. |
| `migrate` | Translate configuration between agent formats locally. |
| `destroy` | Wipe the local vault clone (default) or the remote vault contents via a normal commit. **Local agent files (`~/.claude`, `~/.cursor`, …) are never touched.** |
Expand Down
44 changes: 44 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ When developing from source, replace the binary call with `bun run src/cli.ts`.
| [`doctor`](#doctor) | Check the local environment before blaming sync logic. |
| [`daemon`](#daemon) | Install, start, stop, and inspect the background daemon. |
| [`key`](#key) | Add, list, or remove recipients, or rotate the current machine key. |
| [`config`](#config) | View or change vault config (agents, sync, security policy). |
| [`skill`](#skill) | Remove a skill from the vault. |
| [`plugin`](#plugin) | List or reinstall a machine's Claude plugins from its vault manifest. |
| [`vault`](#vault) | Migrate an older vault to the current format (`vault upgrade`). |
Expand Down Expand Up @@ -323,6 +324,49 @@ agentsync key rotate # new local identity, re-encrypt
- `key remove` refuses to remove the only remaining recipient (a vault must stay decryptable) and refuses to remove the key of the machine you run it on (you cannot deauthorize yourself — run it from another machine to remove a lost one).
- Recipient names are stable config keys and are visible in the vault repository. Use names that describe the machine clearly without leaking sensitive context.

## config

**Why**: View or change the vault configuration in `agentsync.toml` without hand-editing it — which agents are enabled, daemon sync behaviour, and the secret-handling policy.

**Usage**:

```bash
agentsync config list # print every configurable key
agentsync config get sync.debounceMs # read one value
agentsync config set agents.vscode true # enable VS Code sync
agentsync config set security.secretScan strict # widen secret detection
agentsync config set security.allowSecretValues '["AKIA-not-a-real-key"]'
```

**Settable keys** (dotted paths under these sections):

| Key | Type | Meaning |
|---|---|---|
| `agents.<claude\|cursor\|codex\|copilot\|vscode>` | boolean | Whether that agent is snapshotted on push. |
| `sync.debounceMs` | integer 50–10000 | Daemon quiet-window before an auto-push. |
| `sync.autoPush` | boolean | Whether the daemon auto-pushes on change. |
| `claudePlugins.syncPlugins` | boolean | Record the Claude plugin reinstall manifest on push. |
| `security.secretScan` | `standard`\|`strict`\|`off` | Push-time secret-scan mode (see note). |
| `security.allowSecretValues` | string[] (JSON) | Literal values exempt from secret detection and base64 redaction (see note). |
| `security.redactBase64Values` | boolean | Replace long base64-looking JSON values with a redaction placeholder (see note). |

> **`security.*` are recorded but not yet enforced.** This release stores the
> policy in `agentsync.toml`; the push-time secret scanner starts honouring
> `secretScan`, `allowSecretValues`, and `redactBase64Values` in a follow-up
> change. Until then the scan runs with its built-in defaults regardless of
> these values. Note `agentsync.toml` is committed in **plaintext** (only
> artefacts are encrypted), so `allowSecretValues` is for exempting legitimate
> high-entropy *non-secret* values — never paste a real credential there.
> `config set` refuses a recognised credential in any other value.

**Outcome**: `list` and `get` are read-only. `set` validates the new value against the full config schema (so an out-of-range debounce or an invalid enum is rejected before anything is written), then — because `agentsync.toml` is shared across machines — reconciles fast-forward, commits, and pushes the change, exactly like `key add`.

**Caveats**:

- `version`, `recipients`, and `remote` are **not** settable here. Recipients are managed by [`key`](#key); the remote is fixed at [`init`](#init); the format version is the old-binary guard.
- A value is parsed as JSON first (`true`, `500`, `["x"]`), falling back to a plain string for bare words (`strict`). Quote a JSON array in your shell.
- `set` reconciles against the remote first, so it fails closed on diverged history like every other vault-writing command.

## skill

**Why**: Remove a skill from the vault without affecting the rest of the snapshot. This is AgentSync's only explicit, non-additive operation.
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { defineCommand, runMain } from "citty";
import { configCommand } from "./commands/config";
import { copyCommand } from "./commands/copy";
import { daemonCommand } from "./commands/daemon";
import { destroyCommand } from "./commands/destroy";
Expand Down Expand Up @@ -30,6 +31,7 @@ const main = defineCommand({
doctor: doctorCommand,
daemon: daemonCommand,
key: keyCommand,
config: configCommand,
migrate: migrateCommand,
skill: skillCommand,
plugin: pluginCommand,
Expand Down
215 changes: 215 additions & 0 deletions src/commands/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Tests for the `agentsync config` verbs against real git fixtures (no git
* mocking), mirroring key.test.ts. Exercises list/get/set, the settable-prefix
* guard, unknown-key rejection, schema-backed value validation, and scalar
* type coercion.
*/
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { createRequire } from "node:module";
import { join } from "node:path";
import { loadConfig, resolveConfigPath } from "../../config/loader";
import {
createBareRepo,
createMachineFixture,
createTmpDir,
runGit,
seedVaultRepo,
type TestMachineFixture,
} from "../../test-helpers/fixtures";

{
const require = createRequire(import.meta.url);
// biome-ignore lint/style/useNodejsImportProtocol: deliberate alias to bypass mock cache
const realFsPromises = require("fs/promises") as typeof import("node:fs/promises");
mock.module("node:fs/promises", () => ({ ...realFsPromises, default: realFsPromises }));
}

mock.module("@clack/prompts", () => ({
intro: () => {},
outro: () => {},
log: { success: () => {}, info: () => {}, warn: () => {}, error: () => {} },
note: () => {},
spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }),
}));

type ConfigMod = typeof import("../config");
let configMod: ConfigMod;

const RUNTIME_ENV_KEYS = ["AGENTSYNC_VAULT_DIR", "AGENTSYNC_KEY_PATH", "AGENTSYNC_MACHINE"];

afterAll(() => {
mock.restore();
});

describe("config command", () => {
let tmpDir: string;
let machine: TestMachineFixture;
let bare: string;
const savedEnv: Record<string, string | undefined> = {};

beforeEach(async () => {
configMod = await import("../config");
tmpDir = await createTmpDir();
machine = await createMachineFixture(tmpDir, "config-test");
bare = await createBareRepo(tmpDir);
seedVaultRepo({ machine, bareRepoPath: bare });
for (const key of RUNTIME_ENV_KEYS) savedEnv[key] = process.env[key];
process.env.AGENTSYNC_VAULT_DIR = machine.vaultDir;
process.env.AGENTSYNC_KEY_PATH = machine.keyPath;
process.env.AGENTSYNC_MACHINE = machine.machineName;
process.exitCode = 0;
});

function restore(): Promise<void> {
for (const key of RUNTIME_ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
return rm(tmpDir, { recursive: true, force: true });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// afterEach guarantees env + tmpdir cleanup even when an assertion throws
// mid-test, so a failing test cannot leak dirty state into the next one.
afterEach(restore);

test("performConfigList returns settable keys and excludes version + recipients", async () => {
const entries = await configMod.performConfigList();
const keys = entries.map((e) => e.key);
expect(keys).toContain("agents.claude");
expect(keys).toContain("sync.debounceMs");
expect(keys).toContain("security.secretScan");
expect(keys.some((k) => k === "version")).toBe(false);
expect(keys.some((k) => k.startsWith("recipients"))).toBe(false);
});

test("performConfigGet reads a value and reports unknown keys", async () => {
const found = await configMod.performConfigGet("security.secretScan");
expect(found).toEqual({ status: "found", key: "security.secretScan", value: "standard" });
const missing = await configMod.performConfigGet("agents.nope");
expect(missing.status).toBe("unknown-key");
});

test("performConfigSet changes a boolean and persists it to the vault config", async () => {
const result = await configMod.performConfigSet("agents.vscode", "true");
expect(result.status).toBe("success");
const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.agents.vscode).toBe(true);
});

test("performConfigSet coerces a numeric string and an enum word", async () => {
const num = await configMod.performConfigSet("sync.debounceMs", "500");
expect(num.status).toBe("success");
if (num.status === "success") expect(num.newValue).toBe(500);

const enumSet = await configMod.performConfigSet("security.secretScan", "strict");
expect(enumSet.status).toBe("success");

const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.sync.debounceMs).toBe(500);
expect(config.security.secretScan).toBe("strict");
});

test("performConfigSet sets an array value from JSON", async () => {
const result = await configMod.performConfigSet(
"security.allowSecretValues",
'["AKIAEXAMPLE","ghp_example"]',
);
expect(result.status).toBe("success");
const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.security.allowSecretValues).toEqual(["AKIAEXAMPLE", "ghp_example"]);
});

test("performConfigSet refuses protected sections", async () => {
for (const key of ["version", "recipients.config-test", "remote.url"]) {
const result = await configMod.performConfigSet(key, "x");
expect(result.status).toBe("not-settable");
}
});

test("performConfigSet rejects an unknown key under a settable section", async () => {
const result = await configMod.performConfigSet("agents.cluade", "true");
expect(result.status).toBe("unknown-key");
});

test("performConfigSet rejects a value the schema forbids", async () => {
const tooSmall = await configMod.performConfigSet("sync.debounceMs", "5");
expect(tooSmall.status).toBe("invalid-value");

const badEnum = await configMod.performConfigSet("security.secretScan", "loud");
expect(badEnum.status).toBe("invalid-value");

// The vault config is unchanged after rejected sets.
const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.sync.debounceMs).toBe(300);
expect(config.security.secretScan).toBe("standard");
});

test("performConfigSet coerces a false boolean", async () => {
const result = await configMod.performConfigSet("sync.autoPush", "false");
expect(result.status).toBe("success");
if (result.status === "success") expect(result.newValue).toBe(false);
const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.sync.autoPush).toBe(false);
});

test("performConfigSet refuses a prototype-pollution key and leaves Object.prototype intact", async () => {
const result = await configMod.performConfigSet(
"security.__proto__.toLocaleString",
'"polluted"',
);
// Rejected before any write: the prototype-walk segment is not an own key.
expect(result.status).toBe("unknown-key");
// The global prototype is untouched.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
expect(Object.prototype.toLocaleString).toBeInstanceOf(Function);
});

test("performConfigSet pushes the change to the remote vault", async () => {
const result = await configMod.performConfigSet("sync.debounceMs", "750");
expect(result.status).toBe("success");
// Inspect the bare remote directly — the change must land there, not just
// in the local working copy, because agentsync.toml is shared across machines.
const onRemote = runGit(["show", "HEAD:agentsync.toml"], bare);
expect(onRemote).toContain("debounceMs = 750");
});

test("performConfigSet fails closed on diverged history", async () => {
// Advance the remote independently from a second clone.
const otherClone = join(tmpDir, "other-clone");
runGit(["clone", bare, otherClone]);
runGit(["config", "user.email", "t@t.local"], otherClone);
runGit(["config", "user.name", "t"], otherClone);
writeFileSync(join(otherClone, "remote-extra.txt"), "remote\n", "utf8");
runGit(["add", "."], otherClone);
runGit(["commit", "-m", "remote advance"], otherClone);
runGit(["push", "origin", "main"], otherClone);

// Advance the local vault on a divergent commit.
writeFileSync(join(machine.vaultDir, "local-extra.txt"), "local\n", "utf8");
runGit(["add", "."], machine.vaultDir);
runGit(["commit", "-m", "local advance"], machine.vaultDir);

const result = await configMod.performConfigSet("agents.vscode", "true");
expect(result.status).toBe("failed");
});

test("performConfigSet refuses a literal secret pasted into a config value", async () => {
// A GitHub classic PAT shape pasted into the wrong field. agentsync.toml is
// committed in plaintext, so this must be rejected before any write.
const token = `ghp_${"a".repeat(36)}`;
const result = await configMod.performConfigSet("agents.vscode", token);
expect(result.status).toBe("invalid-value");
if (result.status === "invalid-value") expect(result.error).toMatch(/secret/i);
});

test("performConfigSet allows secret-shaped values in the allowlist (its purpose)", async () => {
const token = `ghp_${"b".repeat(36)}`;
const result = await configMod.performConfigSet("security.allowSecretValues", `["${token}"]`);
expect(result.status).toBe("success");
const config = await loadConfig(resolveConfigPath(machine.vaultDir));
expect(config.security.allowSecretValues).toEqual([token]);
});
});
2 changes: 2 additions & 0 deletions src/commands/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ describe("integration", () => {
autoPush: true,
},
claudePlugins: { syncPlugins: false },
security: { secretScan: "standard", allowSecretValues: [], redactBase64Values: true },
});
writeFileSync(join(machineB.vaultDir, ".gitignore"), "*.tmp\n", "utf8");
runGit(["init"], machineB.vaultDir);
Expand Down Expand Up @@ -643,6 +644,7 @@ describe("integration", () => {
autoPush: true,
},
claudePlugins: { syncPlugins: false },
security: { secretScan: "standard", allowSecretValues: [], redactBase64Values: true },
});
writeFileSync(join(machineB.vaultDir, ".gitignore"), "*.tmp\n", "utf8");
runGit(["init"], machineB.vaultDir);
Expand Down
Loading
Loading