fix(cli): close second review on prime-agent sync + switcher (auth, ownership, switching)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 3m7s
CI/CD / smoke (pull_request) Failing after 3m13s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m4s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 3m7s
CI/CD / smoke (pull_request) Failing after 3m13s
CI/CD / build (pull_request) Successful in 2m12s
CI/CD / publish (pull_request) Has been skipped
Addresses the second round of `config prime-agent` review (10 findings). auth.json (config/prime-agent.ts) — the settings.json data-loss fix had a twin: - loadPrimeAgentAuth now fails loudly on corrupt JSON instead of swallow-and- rewrite, so one syntax error can no longer destroy the provider API key and every other project's credential. hasPrimeAgentAuth shares that guarantee. - writePrimeAgentAuth writes 0600 (preserving an existing file's mode) instead of the default umask — bearer tokens are no longer world-readable on first creation. state ownership (commands/skills.ts) — the ownership model edge cases: - orphan-removal guard now normalises a canonical scope (project name, or null for globals; legacy undefined adopted to current scope) instead of comparing null against undefined, so global-only syncs and pre-PR state can no longer leave stale skills on disk forever. - a same-named skill *tracked* to a different project is preserved (with a warning) rather than silently overwritten in the shared flat tree. - mcpServers auto-attach is gated behind !isPrimeAgent with an explicit warning (prime-agent's HTTP gateway must not mutate shared mcpd project attachments); this also makes the earlier dropped-attach concern explicit rather than silent. single-active project + switcher (config/prime-agent.ts, prime-agent-extension.ts): - registerPrimeAgentMcp tags the project's entry mcpctlManaged:true and removes other mcpctl-managed entries, so prime-agent has one *active* mcpctl project while preserving untagged servers (hand-configured sre, websearch, etc). - the /mcpctl extension now reads that tag as the single source of truth for the active project, fixing the false short-circuit / no-op switch. config.ts command: - an explicit -p now updates a differing up-tree .mcpctl-project marker (scope no longer silently reverts on the next sync), no-ops when it matches, and still never scopes $HOME. - a project left with no usable credential now exits non-zero (the /mcpctl extension checks child exit status, so it no longer reports a successful switch after provisioning failed). - skills sync is treated as best-effort: settings+auth determine switch success, so a skills error no longer falsely fails the switch. - token minting now revokes prior active `prime-agent` tokens before creating a fresh one (no more never-expiring token litter / lost-credential duplication). Tests (544 green): corrupt auth.json refusal, 0600 mode, mint-failure exit code, single-active dedup preserving untagged sre, cross-project overwrite preservation, and global-orphan removal on global-only sync.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
@@ -61,6 +61,7 @@ describe('config prime-agent', () => {
|
||||
expect(written.mcpServers['homeautomation']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`,
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
expect(output.join('\n')).toContain('homeautomation');
|
||||
});
|
||||
@@ -86,6 +87,7 @@ describe('config prime-agent', () => {
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`,
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,6 +218,45 @@ describe('config prime-agent', () => {
|
||||
expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('refusing to overwrite');
|
||||
expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('exits non-zero when a credential cannot be provisioned', async () => {
|
||||
// mockClient post returns { token: ... } by default; override to no token.
|
||||
const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client;
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
expect(process.exitCode).toBe(1);
|
||||
// body of provisioning error surfaced
|
||||
expect(output.join('\n')).toContain('no token returned');
|
||||
});
|
||||
|
||||
it('writes auth.json with mode 0600', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token
|
||||
const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('refuses to overwrite a corrupt settings.json', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, '{ this is not valid json !!!');
|
||||
@@ -235,6 +276,27 @@ describe('config prime-agent', () => {
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!');
|
||||
});
|
||||
|
||||
it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
mcpServers: {
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set
|
||||
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
|
||||
},
|
||||
}));
|
||||
// Active project is homeautomation (tagged). Switch to labctl.
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active
|
||||
expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved
|
||||
});
|
||||
|
||||
it('merges a re-configured project entry, preserving user-added fields', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
@@ -253,6 +315,7 @@ describe('config prime-agent', () => {
|
||||
type: 'http',
|
||||
url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`,
|
||||
headers: { Authorization: 'Bearer u' }, // user-added field preserved
|
||||
mcpctlManaged: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user