diff --git a/docs/reference/spec-v01-secret-distribution.md b/docs/reference/spec-v01-secret-distribution.md index 7b27903..7b65d86 100644 --- a/docs/reference/spec-v01-secret-distribution.md +++ b/docs/reference/spec-v01-secret-distribution.md @@ -204,7 +204,7 @@ Secret 创建和轮换不由 source branch 自动生成;source branch 只声 - 写入对象固定为 `agentrun-v01/agentrun-v01-tool-github-ssh`,keys 固定为 `id_ed25519`、`known_hosts`、`config`。 - CLI dry-run 只显示输入文件 bytes、SecretRef、key 列表和确认命令,不输出文件内容。 - manager upsert Secret 只返回 resourceVersion、hash suffix、SecretRef 和 redaction 状态,不输出 `data`、`stringData`、private key、known_hosts 或 config 明文。 -- `config` 缺省为只允许 `github.com` 走 `ssh.github.com:443`、`IdentityFile ~/.ssh/id_ed25519`、`StrictHostKeyChecking yes` 和 `UserKnownHostsFile ~/.ssh/known_hosts` 的最小配置;若传入自定义 config,必须包含 `Host` 与 `IdentityFile`。 +- `config` 缺省为只允许 `github.com` 走 `ssh.github.com:443`、`IdentityFile /home/agentrun/.ssh/id_ed25519`、`StrictHostKeyChecking yes` 和 `UserKnownHostsFile /home/agentrun/.ssh/known_hosts` 的最小配置;runner Job 同时注入 `GIT_SSH_COMMAND`,用绝对路径指向挂载的 config、identity 和 known_hosts,避免 OpenSSH 的 `~` 按容器 passwd home 解析到错误目录;若传入自定义 config,必须包含 `Host` 与 `IdentityFile`。 - runtime 消费仍必须通过 `executionPolicy.secretScope.toolCredentials[]` 的 volume projection 挂载到 `/home/agentrun/.ssh`;不得把同一 SSH private key 复制到镜像、ConfigMap、payload 或 transient env。 ## 日志与事件 Redaction diff --git a/src/mgr/tool-credentials.ts b/src/mgr/tool-credentials.ts index 529bb23..dc4131b 100644 --- a/src/mgr/tool-credentials.ts +++ b/src/mgr/tool-credentials.ts @@ -196,10 +196,10 @@ function defaultGithubSshConfig(): string { " HostName ssh.github.com", " User git", " Port 443", - " IdentityFile ~/.ssh/id_ed25519", + " IdentityFile /home/agentrun/.ssh/id_ed25519", " IdentitiesOnly yes", " StrictHostKeyChecking yes", - " UserKnownHostsFile ~/.ssh/known_hosts", + " UserKnownHostsFile /home/agentrun/.ssh/known_hosts", "", ].join("\n"); } diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index 49d072b..2ad9918 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -262,7 +262,8 @@ function codexShellSandbox(policy: ExecutionPolicy): string { } function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { - return items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({ + return [ + ...items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({ name: item.envName, valueFrom: { secretKeyRef: { @@ -270,7 +271,26 @@ function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] key: item.secretKey, }, }, - })); + })), + ...githubSshCommandEnvVars(items), + ]; +} + +function githubSshCommandEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { + const credential = items.find((item): item is ToolCredentialVolumeProjection => item.kind === "volume" && item.tool === "github" && item.purpose === "github-ssh"); + if (!credential) return []; + const mountPath = credential.mountPath.replace(/\/+$/u, ""); + return [{ + name: "GIT_SSH_COMMAND", + value: [ + "ssh", + "-F", `${mountPath}/config`, + "-i", `${mountPath}/id_ed25519`, + "-o", "IdentitiesOnly=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", `UserKnownHostsFile=${mountPath}/known_hosts`, + ].join(" "), + }]; } function toolCredentialVolumeMounts(items: ToolCredentialProjection[]): JsonRecord[] { diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index d322ca1..83373e2 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -49,6 +49,7 @@ const selfTest: SelfTestCase = async (context) => { assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); + assertRunnerJobUsesGithubSshCommand(rendered.manifest as JsonRecord, "/home/agentrun/.ssh"); assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access"); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED"); @@ -219,6 +220,7 @@ process.exit(1); assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assertRunnerJobUsesToolCredentialVolume({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); + assertRunnerJobUsesGithubSshCommand(manifest, "/home/agentrun/.ssh"); assertNoSecretLeak(created); const defaultEndpointJobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: unideskSshToolCredentials }, "job create unidesk ssh default endpoint", "selftest-job-create-unidesk-ssh-default-endpoint", 15_000); const defaultEndpointCreated = await jobClient.post(`/api/v1/runs/${defaultEndpointJobItem.runId}/runner-jobs`, { @@ -333,6 +335,14 @@ function assertRunnerJobUsesG14EgressProxy(manifest: JsonRecord): void { assert.ok(noProxy.includes(".svc"), "NO_PROXY must include Kubernetes Service domains"); } +function assertRunnerJobUsesGithubSshCommand(manifest: JsonRecord, mountPath: string): void { + const value = String(runnerEnvValue(manifest, "GIT_SSH_COMMAND")); + assert.ok(value.includes(`-F ${mountPath}/config`), "GIT_SSH_COMMAND must use the mounted SSH config"); + assert.ok(value.includes(`-i ${mountPath}/id_ed25519`), "GIT_SSH_COMMAND must use the mounted identity by absolute path"); + assert.ok(value.includes(`UserKnownHostsFile=${mountPath}/known_hosts`), "GIT_SSH_COMMAND must use mounted known_hosts by absolute path"); + assert.ok(value.includes("StrictHostKeyChecking=yes"), "GIT_SSH_COMMAND must keep strict host key checking enabled"); +} + function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string, secretName: string, secretKey: string): void { const manifest = rendered.manifest as JsonRecord; const spec = manifest.spec as JsonRecord; diff --git a/src/selftest/cases/46-tool-credentials.ts b/src/selftest/cases/46-tool-credentials.ts index 5817b05..f280b73 100644 --- a/src/selftest/cases/46-tool-credentials.ts +++ b/src/selftest/cases/46-tool-credentials.ts @@ -15,7 +15,7 @@ selftest-private-key-material ${privateKeyFooter} `; const knownHosts = "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey\n"; -const sshConfig = "Host github.com\n HostName ssh.github.com\n User git\n Port 443\n IdentityFile ~/.ssh/id_ed25519\n"; +const sshConfig = "Host github.com\n HostName ssh.github.com\n User git\n Port 443\n IdentityFile /home/agentrun/.ssh/id_ed25519\n UserKnownHostsFile /home/agentrun/.ssh/known_hosts\n"; const selfTest: SelfTestCase = async (context) => { const fakeKubectl = path.join(context.tmp, "fake-tool-kubectl.js"); @@ -124,7 +124,7 @@ function assertNoCredentialLeak(value: unknown): void { assert.equal(text.includes("selftest-private-key-material"), false); assert.equal(text.includes(privateKeyHeader), false); assert.equal(text.includes("AAAAC3NzaC1lZDI1NTE5AAAAIselftestKnownHostsKey"), false); - assert.equal(text.includes("IdentityFile ~/.ssh/id_ed25519"), false); + assert.equal(text.includes("IdentityFile /home/agentrun/.ssh/id_ed25519"), false); } async function runCliJson(context: { root: string }, args: string[]): Promise {