-
Notifications
You must be signed in to change notification settings - Fork 0
feat(config): add agentsync config command and [security] schema section
#185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | ||
| } | ||
|
|
||
| // 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]); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.