From c52ba0dac2ed2ba5a04944cea0256d3933b781d1 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 8 Jun 2026 10:59:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20provider=20profile?= =?UTF-8?q?=20auth.json=20CLI=20=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloud/provider-profile-management.test.ts | 42 +++++++++++++++++++ internal/cloud/provider-profile-management.ts | 5 ++- tools/hwlab-cli/client.test.ts | 23 ++++++++++ tools/src/hwlab-cli-lib.ts | 30 +++++++++++-- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts index d2048187..6b5546bb 100644 --- a/internal/cloud/provider-profile-management.test.ts +++ b/internal/cloud/provider-profile-management.test.ts @@ -139,6 +139,44 @@ test("provider profile set-key delegates through HWLAB without echoing API key", } }); +test("provider profile auth-json delegates through HWLAB without echoing auth json", async () => { + const calls: Array<{ method?: string; path?: string; body?: any }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor, authMethod: "api-key" }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + const authJson = `${JSON.stringify({ OPENAI_API_KEY: "sk-test-provider-secret-123456" })}\n`; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/hy/credential`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: "Bearer hwlab-test" }, + body: JSON.stringify({ authJson }) + }); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.status, "updated"); + assert.equal(body.profile, "hy"); + assert.equal(text.includes("OPENAI_API_KEY"), false); + assert.equal(text.includes("sk-test-provider-secret-123456"), false); + assert.equal(calls[0].method, "PUT"); + assert.equal(calls[0].path, "/api/v1/provider-profiles/hy/credential"); + assert.equal(calls[0].body.authJson, authJson); + assert.equal(calls[0].body.apiKey, undefined); + assert.equal(calls[0].body.delegatedBy.system, "hwlab-v02"); + assert.equal(calls[0].body.delegatedBy.authMethod, "api-key"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + test("provider profile config reads and writes config.toml through AgentRun", async () => { const calls: Array<{ method?: string; path?: string; body?: any }> = []; const agentRunServer = await startAgentRunServer(calls); @@ -256,6 +294,10 @@ async function startAgentRunServer(calls: Array<{ method?: string; path?: string response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_key", data: { action: "provider-profile-credential-updated", mutation: true, profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", requiresExternalBridgeUpdate: true, valuesPrinted: false } })}\n`); return; } + if (request.method === "PUT" && url.pathname === "/api/v1/provider-profiles/hy/credential") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_set_auth_json", data: { action: "provider-profile-credential-updated", mutation: true, profile: "hy", configured: true, credentialSource: "auth-json", secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-hy", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "1234abcd", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", valuesPrinted: false } })}\n`); + return; + } if (request.method === "GET" && url.pathname === "/api/v1/provider-profiles/deepseek/config") { response.end(`${JSON.stringify({ ok: true, traceId: "trc_config", data: { action: "provider-profile-config", profile: "deepseek", configured: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:00:00.000Z", configToml: 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n', configTomlPrinted: true, credentialValuesPrinted: false, valuesPrinted: false } })}\n`); return; diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index fa8d6acd..e376106c 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -65,9 +65,10 @@ export async function handleProviderProfilesHttp(request, response, url, options if (route.kind === "credential") { const body = await readJsonObject(request, options.bodyLimitBytes); const apiKey = text(body.apiKey); - if (apiKey.length < 8) return sendError(response, 400, "api_key_required", "apiKey is required and must not be empty"); + const authJson = typeof body.authJson === "string" ? body.authJson : ""; + if (apiKey.length < 8 && !authJson.trim()) return sendError(response, 400, "credential_required", "apiKey or authJson is required and must not be empty"); const delegatedBody = { - apiKey, + ...(authJson.trim() ? { authJson } : { apiKey }), ...(body.config && typeof body.config === "object" ? { config: body.config } : {}), delegatedBy: delegatedBy(auth, requestId), reason: text(body.reason) || "hwlab-provider-management" diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 92a84db5..ad8ddfa8 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -126,6 +126,29 @@ test("hwlab-cli client provider-profiles set-key reads stdin and never prints ke assert.equal(JSON.stringify(result.payload).includes(secret), false); }); +test("hwlab-cli client provider-profiles set-auth-json reads stdin and never prints auth json", async () => { + const calls: any[] = []; + const authJson = `${JSON.stringify({ OPENAI_API_KEY: "sk-test-provider-secret-123456" })}\n`; + const result = await runHwlabCli(["client", "provider-profiles", "set-auth-json", "hy", "--auth-json-stdin", "--base-url", "http://web.test"], { + stdinText: authJson, + env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); + return new Response(JSON.stringify({ ok: true, status: "updated", profile: "hy", data: { profile: "hy", configured: true, credentialSource: "auth-json", secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-hy", keys: ["auth.json", "config.toml"] }, credentialHashSuffix: "abc12345", configHashSuffix: "def67890", valuesPrinted: false }, valuesPrinted: false }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles/hy/credential"); + assert.equal(calls[0].init.method, "PUT"); + assert.equal(calls[0].body.authJson, authJson); + assert.equal(calls[0].body.apiKey, undefined); + assert.equal(result.payload.action, "client.provider-profiles.set-auth-json"); + assert.equal(result.payload.body.profileStatus.profile, "hy"); + assert.equal(JSON.stringify(result.payload).includes("OPENAI_API_KEY"), false); + assert.equal(JSON.stringify(result.payload).includes("sk-test-provider-secret-123456"), false); +}); + test("hwlab-cli client provider-profiles config reads and writes config.toml", async () => { const calls: any[] = []; const configToml = 'model = "deepseek-chat"\nbase_url = "http://bridge/v1"\n'; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index e52f5430..e8e57e35 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -319,11 +319,20 @@ async function providerProfilesCommand(context: any) { } if (subcommand === "set-key" || subcommand === "set" || subcommand === "credential") { const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); - if (context.parsed.keyStdin !== true) throw cliError("key_stdin_required", "set-key requires --key-stdin so the API key is not passed in argv", { command: "provider-profiles set-key PROFILE --key-stdin" }); - const apiKey = await providerProfileApiKeyFromStdin(context); + const authJsonMode = context.parsed.authJsonStdin === true; + if (!authJsonMode && context.parsed.keyStdin !== true) throw cliError("credential_stdin_required", "set-key requires --key-stdin, or use set-auth-json PROFILE --auth-json-stdin for Codex auth.json", { command: "provider-profiles set-key PROFILE --key-stdin" }); + const credentialBody = authJsonMode ? { authJson: await providerProfileAuthJsonFromStdin(context) } : { apiKey: await providerProfileApiKeyFromStdin(context) }; const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`; - const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { apiKey }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); - return responsePayload("client.provider-profiles.set-key", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); + const response = await requestJson({ ...context, method: "PUT", path: pathName, body: credentialBody, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + return responsePayload(authJsonMode ? "client.provider-profiles.set-auth-json" : "client.provider-profiles.set-key", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); + } + if (subcommand === "set-auth-json" || subcommand === "auth-json" || subcommand === "set-auth") { + const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); + if (context.parsed.authJsonStdin !== true) throw cliError("auth_json_stdin_required", "set-auth-json requires --auth-json-stdin so auth.json is not passed in argv", { command: "provider-profiles set-auth-json PROFILE --auth-json-stdin" }); + const authJson = await providerProfileAuthJsonFromStdin(context); + const pathName = `/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`; + const response = await requestJson({ ...context, method: "PUT", path: pathName, body: { authJson }, timeoutMs: numberOption(context.parsed.submitTimeoutMs) ?? DEFAULT_TIMEOUT_MS }); + return responsePayload("client.provider-profiles.set-auth-json", response, context, { route: route("PUT", pathName), profile, body: providerProfileBodyForCli(response.body, context.parsed), valuesPrinted: false }); } if (subcommand === "config") { const profile = normalizeProviderProfile(context.parsed.profileName ?? context.parsed.profileId ?? context.rest[1]); @@ -406,6 +415,7 @@ function providerProfilesHelp() { "remove PROFILE", "set-config PROFILE --config-stdin", "set-key PROFILE --key-stdin", + "set-auth-json PROFILE --auth-json-stdin", "validate PROFILE [--wait] [--timeout-ms N] [--poll-interval-ms N]", "validation PROFILE VALIDATION_ID" ], @@ -433,6 +443,18 @@ async function providerProfileConfigFromStdin(context: any) { return configToml; } +async function providerProfileAuthJsonFromStdin(context: any) { + const authJson = context.stdinText !== undefined ? String(context.stdinText) : await readRawStdin(); + if (!authJson.trim()) throw cliError("auth_json_empty", "provider profile auth.json from stdin must not be empty"); + try { + const parsed = JSON.parse(authJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("auth.json object expected"); + } catch { + throw cliError("auth_json_invalid", "provider profile auth.json from stdin must be a JSON object"); + } + return authJson; +} + function normalizeProviderProfile(value: unknown) { const profile = text(value).toLowerCase(); const normalized = profile === "codex" ? "codex-api" : profile;