Skip to content

Commit 7fd2002

Browse files
Copilothuberp
andauthored
feat: --api-key CLI override + CI oneshot workflow (#100)
* Initial plan * feat: --api-key CLI override encapsulated in config.ts for all start modes Agent-Logs-Url: https://github.com/huberp/agentloop/sessions/587451dc-93d3-494c-8bc3-23367dd6b13c Co-authored-by: huberp <4027454+huberp@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: huberp <4027454+huberp@users.noreply.github.com>
1 parent 6fc4ff0 commit 7fd2002

6 files changed

Lines changed: 447 additions & 4 deletions

File tree

.env.test

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Test environment defaults for agentloop.
2+
#
3+
# MISTRAL_API_KEY is intentionally absent — supply it via the --api-key CLI
4+
# flag so that secrets are never committed to the repository:
5+
#
6+
# npm run oneshot -- agent --api-key "$MISTRAL_API_KEY" -u "Your prompt here"
7+
#
8+
# In CI the GitHub Actions workflow injects the key through that flag using the
9+
# MISTRAL_API_KEY repository secret.
10+
#
11+
# To run locally:
12+
# cp .env.test .env
13+
# npm run oneshot -- agent --api-key "sk-..." -u "Your prompt here"
14+
15+
# Agent loop — keep iterations low for fast, cheap tests
16+
MAX_ITERATIONS=5
17+
MAX_TOKENS_BUDGET=0
18+
MAX_CONTEXT_TOKENS=28000
19+
20+
# LLM retry settings
21+
LLM_RETRY_MAX=2
22+
LLM_RETRY_BASE_DELAY_MS=500
23+
24+
# Per-tool execution timeout
25+
TOOL_TIMEOUT_MS=30000
26+
27+
# LLM provider — use a fast, affordable model for tests
28+
LLM_PROVIDER=mistral
29+
LLM_MODEL=mistral-small-latest
30+
LLM_TEMPERATURE=0.0
31+
32+
# System prompt override (none by default)
33+
SYSTEM_PROMPT_PATH=
34+
35+
# Skip all confirmation prompts in non-interactive test runs
36+
AUTO_APPROVE_ALL=true
37+
TOOL_ALLOWLIST=
38+
TOOL_BLOCKLIST=
39+
40+
# Shell command execution
41+
SHELL_COMMAND_BLOCKLIST=
42+
43+
# Code execution
44+
EXECUTION_TIMEOUT_MS=60000
45+
EXECUTION_ENVIRONMENT=local
46+
47+
# Sandboxing (keep off for tests)
48+
SANDBOX_MODE=none
49+
SANDBOX_DOCKER_IMAGE=node:20-alpine
50+
51+
# Workspace
52+
WORKSPACE_ROOT=
53+
54+
# Instruction files
55+
INSTRUCTIONS_ROOT=
56+
57+
# Prompt templates / history (disabled in tests)
58+
PROMPT_TEMPLATES_DIR=
59+
PROMPT_HISTORY_FILE=
60+
PROMPT_CONTEXT_REFRESH_MS=5000
61+
62+
# MCP (disabled in tests)
63+
MCP_SERVERS=
64+
65+
# Security hardening
66+
MAX_FILE_SIZE_BYTES=10485760
67+
MAX_SHELL_OUTPUT_BYTES=1048576
68+
MAX_CONCURRENT_TOOLS=10
69+
NETWORK_ALLOWED_DOMAINS=
70+
71+
# Streaming — off for deterministic test output
72+
STREAMING_ENABLED=false
73+
74+
# Tracing — off for tests
75+
TRACING_ENABLED=false
76+
TRACE_OUTPUT_DIR=./traces
77+
TRACING_COST_PER_INPUT_TOKEN_USD=0
78+
TRACING_COST_PER_OUTPUT_TOKEN_USD=0
79+
80+
# Logging — minimal output during tests
81+
LOG_LEVEL=warn
82+
LOG_ENABLED=true
83+
LOG_DESTINATION=stdout
84+
LOG_NAME=agentloop
85+
LOG_TIMESTAMP=false
86+
87+
# Web search — disabled by default to avoid network calls in tests
88+
WEB_SEARCH_PROVIDER=none
89+
TAVILY_API_KEY=
90+
TAVILY_MAX_RESULTS=5
91+
LANGSEARCH_API_KEY=
92+
LANGSEARCH_MAX_RESULTS=5
93+
DUCKDUCKGO_MAX_RESULTS=5
94+
DUCKDUCKGO_MIN_DELAY_MS=1000
95+
DUCKDUCKGO_RETRY_MAX=2
96+
DUCKDUCKGO_RETRY_BASE_DELAY_MS=400
97+
DUCKDUCKGO_RATE_LIMIT_PENALTY_MS=1000
98+
DUCKDUCKGO_CACHE_TTL_MS=300000
99+
DUCKDUCKGO_CACHE_MAX_ENTRIES=128
100+
DUCKDUCKGO_SERVE_STALE_ON_ERROR=true
101+
102+
# Web fetch tool
103+
WEB_DOMAIN_BLOCKLIST=
104+
WEB_DOMAIN_ALLOWLIST=
105+
WEB_ALLOW_HTTP=false
106+
WEB_MAX_RESPONSE_BYTES=5242880
107+
WEB_MAX_CONTENT_CHARS=20000
108+
WEB_USER_AGENT=AgentLoop/1.0
109+
WEB_FETCH_TIMEOUT_MS=15000
110+
111+
# Runtime context injection — off for reproducible test output
112+
RUNTIME_CONTEXT_ENABLED=false
113+
114+
# Interactive UI mode
115+
UI_MODE=cli
116+
117+
# Skills / agent profiles (use built-ins only)
118+
SKILLS_DIR=
119+
AGENT_PROFILES_DIR=
120+
121+
# LLM response recording (off for tests)
122+
RECORD_LLM_RESPONSES=false
123+
LLM_FIXTURE_DIR=tests/fixtures/llm-responses
124+
125+
# Orchestrator engine
126+
ORCHESTRATOR=default
127+
128+
# Coordinator (off for tests)
129+
COORDINATOR_ENABLED=false
130+
COORDINATOR_PLAN_THRESHOLD=1

.github/workflows/cli-test.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: CLI Test
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
cli-oneshot:
13+
name: CLI Oneshot Test
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v6
17+
18+
- uses: actions/setup-node@v6
19+
with:
20+
node-version: 20
21+
cache: npm
22+
23+
- name: Install dependencies
24+
run: npm ci
25+
26+
- name: Set up test environment
27+
run: cp .env.test .env
28+
29+
- name: Run oneshot agent (oneshot mode)
30+
run: |
31+
npm run oneshot -- agent \
32+
--api-key "$MISTRAL_API_KEY" \
33+
--json \
34+
-u "Reply with exactly one word: hello"
35+
env:
36+
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,28 @@ npm run oneshot -- list providers
176176
| `skills` | All active skills with name, source, description |
177177
| `providers` | Configured LLM and search providers with their active status |
178178

179+
### Global option: `--api-key`
180+
181+
Every subcommand accepts a global `--api-key` flag that overrides the `MISTRAL_API_KEY` value from `.env`.
182+
This makes it easy to supply the key from CI secrets or per-invocation without modifying your environment file:
183+
184+
```bash
185+
# Provide the API key directly on the command line
186+
npm run oneshot -- agent --api-key "sk-my-key" -u "Summarise the README"
187+
188+
# Useful in CI pipelines:
189+
npm run oneshot -- agent --api-key "$MISTRAL_API_KEY" -u "Run a quick sanity check"
190+
```
191+
192+
The override is handled entirely inside the `config` module (`applyApiKeyOverride`) so no other part of the codebase needs to know where the key came from.
193+
194+
For CI use, copy `.env.test` (which intentionally omits `MISTRAL_API_KEY`) to `.env` and inject the key via `--api-key`:
195+
196+
```bash
197+
cp .env.test .env
198+
npm run oneshot -- agent --api-key "$MISTRAL_API_KEY" -u "Your prompt"
199+
```
200+
179201
## Deployment
180202

181203
### Docker

src/__tests__/start-oneshot.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,3 +590,182 @@ describe("start-oneshot: getActiveExecutor", () => {
590590
}
591591
});
592592
});
593+
594+
// ---------------------------------------------------------------------------
595+
// Tests: applyApiKeyOverride() in config module
596+
// ---------------------------------------------------------------------------
597+
598+
describe("config: applyApiKeyOverride", () => {
599+
it("applyApiKeyOverride is exported from config", async () => {
600+
const config = await import("../config");
601+
expect(typeof config.applyApiKeyOverride).toBe("function");
602+
});
603+
604+
it("updates appConfig.mistralApiKey", async () => {
605+
const config = await import("../config");
606+
const original = config.appConfig.mistralApiKey;
607+
try {
608+
config.applyApiKeyOverride("sk-override-test");
609+
expect(config.appConfig.mistralApiKey).toBe("sk-override-test");
610+
} finally {
611+
config.applyApiKeyOverride(original);
612+
}
613+
});
614+
615+
it("updates process.env.MISTRAL_API_KEY", async () => {
616+
const config = await import("../config");
617+
const original = process.env.MISTRAL_API_KEY;
618+
try {
619+
config.applyApiKeyOverride("sk-env-test");
620+
expect(process.env.MISTRAL_API_KEY).toBe("sk-env-test");
621+
} finally {
622+
process.env.MISTRAL_API_KEY = original;
623+
config.applyApiKeyOverride(original ?? "");
624+
}
625+
});
626+
627+
it("keeps appConfig.mistralApiKey and process.env in sync after override", async () => {
628+
const config = await import("../config");
629+
const original = config.appConfig.mistralApiKey;
630+
try {
631+
config.applyApiKeyOverride("sk-sync-test");
632+
expect(config.appConfig.mistralApiKey).toBe(process.env.MISTRAL_API_KEY);
633+
} finally {
634+
config.applyApiKeyOverride(original);
635+
}
636+
});
637+
});
638+
639+
// ---------------------------------------------------------------------------
640+
// Tests: early argv scan in config.ts (applyCliApiKeyOverride)
641+
//
642+
// The IIFE in config.ts reads process.argv at module-load time to apply
643+
// --api-key before appConfig is built. These tests verify the logic by
644+
// directly replicating the scan behaviour so the module-singleton limitation
645+
// does not interfere.
646+
// ---------------------------------------------------------------------------
647+
648+
describe("config: early --api-key argv scan logic", () => {
649+
// Replicates the IIFE logic: returns the value, or undefined if absent,
650+
// or throws if --api-key is present but has no valid value (consistent with
651+
// the exit(1) in the real IIFE and with stripApiKeyArg).
652+
function simulateArgvScan(argv: string[]): string | undefined {
653+
const idx = argv.indexOf("--api-key");
654+
if (idx === -1) return undefined;
655+
const value = argv[idx + 1];
656+
if (!value || value.startsWith("-")) {
657+
throw new Error("Error: --api-key requires a value");
658+
}
659+
return value;
660+
}
661+
662+
it("extracts the api key when --api-key is present with a value", () => {
663+
const result = simulateArgvScan(["node", "script.js", "--api-key", "sk-early"]);
664+
expect(result).toBe("sk-early");
665+
});
666+
667+
it("returns undefined when --api-key is absent", () => {
668+
const result = simulateArgvScan(["node", "script.js", "-u", "hello"]);
669+
expect(result).toBeUndefined();
670+
});
671+
672+
it("errors when --api-key has no value (consistent with stripApiKeyArg)", () => {
673+
expect(() => simulateArgvScan(["node", "script.js", "--api-key"])).toThrow(
674+
"--api-key requires a value"
675+
);
676+
});
677+
678+
it("errors when --api-key value starts with a dash (consistent with stripApiKeyArg)", () => {
679+
expect(() =>
680+
simulateArgvScan(["node", "script.js", "--api-key", "--other-flag"])
681+
).toThrow("--api-key requires a value");
682+
});
683+
684+
it("works when --api-key appears after the subcommand (oneshot pattern)", () => {
685+
const result = simulateArgvScan(["node", "script.js", "agent", "--api-key", "sk-sub", "-u", "hi"]);
686+
expect(result).toBe("sk-sub");
687+
});
688+
689+
it("covers all start modes — cli, tui, oneshot, and direct index", () => {
690+
// All start modes use the same config module, so the argv scan fires once
691+
// before appConfig is built, regardless of which entry point is used.
692+
const cliResult = simulateArgvScan(["node", "start-cli.ts", "--api-key", "sk-cli"]);
693+
const tuiResult = simulateArgvScan(["node", "start-tui.ts", "--api-key", "sk-tui"]);
694+
const oneshotResult = simulateArgvScan(["node", "start-oneshot.ts", "agent", "--api-key", "sk-oneshot", "-u", "hi"]);
695+
expect(cliResult).toBe("sk-cli");
696+
expect(tuiResult).toBe("sk-tui");
697+
expect(oneshotResult).toBe("sk-oneshot");
698+
});
699+
});
700+
701+
// ---------------------------------------------------------------------------
702+
// Tests: stripApiKeyArg() in config module
703+
// ---------------------------------------------------------------------------
704+
705+
describe("config: stripApiKeyArg", () => {
706+
it("stripApiKeyArg is exported from config", async () => {
707+
const config = await import("../config");
708+
expect(typeof config.stripApiKeyArg).toBe("function");
709+
});
710+
711+
it("removes --api-key and its value from the array", async () => {
712+
const { stripApiKeyArg } = await import("../config");
713+
const result = stripApiKeyArg(["--api-key", "sk-my-key", "-u", "Hello"]);
714+
expect(result).toEqual(["-u", "Hello"]);
715+
expect(result.includes("--api-key")).toBe(false);
716+
});
717+
718+
it("returns the array unchanged when --api-key is absent", async () => {
719+
const { stripApiKeyArg } = await import("../config");
720+
const result = stripApiKeyArg(["-u", "Hello"]);
721+
expect(result).toEqual(["-u", "Hello"]);
722+
});
723+
724+
it("strips --api-key placed after other flags", async () => {
725+
const { stripApiKeyArg } = await import("../config");
726+
const result = stripApiKeyArg(["-u", "Hello", "--api-key", "sk-after"]);
727+
expect(result).toEqual(["-u", "Hello"]);
728+
});
729+
730+
it("does not mutate the original array", async () => {
731+
const { stripApiKeyArg } = await import("../config");
732+
const original = ["-u", "Hello", "--api-key", "sk-key"];
733+
const result = stripApiKeyArg(original);
734+
expect(original).toEqual(["-u", "Hello", "--api-key", "sk-key"]);
735+
expect(result).toEqual(["-u", "Hello"]);
736+
});
737+
});
738+
739+
// ---------------------------------------------------------------------------
740+
// Tests: --api-key global option pre-processing in start-oneshot main()
741+
// (delegates to config.stripApiKeyArg — verified via the config tests above)
742+
// ---------------------------------------------------------------------------
743+
744+
describe("start-oneshot: --api-key is stripped before subcommand dispatch", () => {
745+
it("stripApiKeyArg produces clean args consumable by parseArgs strict mode", async () => {
746+
const { stripApiKeyArg } = await import("../config");
747+
// Simulate: agent --api-key sk-key -u "Hello"
748+
const cleaned = stripApiKeyArg(["--api-key", "sk-key", "-u", "Hello"]);
749+
// parseArgs with strict:true must not throw on the cleaned array
750+
expect(() =>
751+
parseArgs({
752+
args: cleaned,
753+
options: {
754+
system: { type: "string", short: "s" },
755+
user: { type: "string", short: "u" },
756+
profile: { type: "string", short: "p" },
757+
stream: { type: "boolean" },
758+
json: { type: "boolean" },
759+
},
760+
strict: true,
761+
allowPositionals: false,
762+
})
763+
).not.toThrow();
764+
const { values } = parseArgs({
765+
args: cleaned,
766+
options: { user: { type: "string", short: "u" } },
767+
strict: false,
768+
});
769+
expect(values.user).toBe("Hello");
770+
});
771+
});

0 commit comments

Comments
 (0)