diff --git a/.gitignore b/.gitignore index 244fcb77..f9b855da 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ coverage.txt # Output of the go coverage tool, specifically when used with LiteIDE *.out +.env +.env.* +!.env.example + # Dependency directories (remove the comment below to include it) # vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a55fb0ed..0a56314b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 2.19.7 +## 新增 +1. `qshell sandbox injection-rule create` / `update` 与 `qshell sandbox create --inline-injection` 新增 `github` 注入类型:通过 `--api-key` / `api-key=` 传入 GitHub Token;平台克隆仓库及匹配 `github.com` / `api.github.com` 出站请求时自动注入 token,沙箱内不可见明文 +2. `qshell sandbox create` 新增 `--resource` 参数,支持在沙箱启动前由平台拉取 GitHub 仓库快照并挂载到指定路径,格式 `type=github_repository,url=,mount-path=,token=`(`type` 可省略,`mount-path` 也可写作 `mount`;同一沙箱内多条 `--resource` 必须共用同一 token) + +## 更新 +1. 升级 `github.com/qiniu/go-sdk/v7` 到 `v7.26.12`,附带修复其内部 `Commands.Connect` 重复关闭 channel 引发的 panic,并对 `GitRepositoryResource` 必填字段进行 SDK 侧校验 + # 2.19.6 ## 修复 1. 修复 `qshell sandbox template build` 在 rebuild 场景下清空 `qshell.sandbox.toml` 中 `from_image` / `from_template` 的问题,避免配置了父模板的 Dockerfile 回退到 `FROM scratch` 后构建失败;CLI 显式传入 `--from-image` / `--from-template` 时仍会在 rebuild 场景报错 diff --git a/cmd/sandbox.go b/cmd/sandbox.go index 1cb7e6e7..df31aa17 100644 --- a/cmd/sandbox.go +++ b/cmd/sandbox.go @@ -120,7 +120,17 @@ var sandboxCreateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { --inline-injection 'type=openai,api-key=sk-xxx' \ --inline-injection 'type=http,base-url=https://api.example.com,headers=Authorization=Bearer token;X-Env=prod' qshell sbx cr my-template \ - --inline-injection 'type=openai,api-key=sk-xxx'`, + --inline-injection 'type=openai,api-key=sk-xxx' + + # Create with a GitHub credential inline injection (token passed via api-key) + qshell sandbox create my-template --inline-injection 'type=github,api-key=ghp-xxx' + qshell sbx cr my-template --inline-injection 'type=github,api-key=ghp-xxx' + + # Create with a GitHub repository resource mounted into the sandbox + qshell sandbox create my-template \ + --resource 'type=github_repository,url=https://github.com/owner/repo.git,mount-path=/workspace/repo,token=ghp-xxx' + qshell sbx cr my-template \ + --resource 'url=https://github.com/owner/repo.git,mount-path=/workspace/repo,token=ghp-xxx'`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { cfg.CmdCfg.CmdId = docs.SandboxCreateType @@ -140,6 +150,7 @@ var sandboxCreateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { cmd.Flags().BoolVar(&info.AutoPause, "auto-pause", false, "automatically pause sandbox when timeout expires (instead of killing)") cmd.Flags().StringArrayVar(&info.InjectionRuleID, "injection-rule", nil, "injection rule IDs to apply when creating the sandbox (can be specified multiple times)") cmd.Flags().StringArrayVar(&info.InlineInjection, "inline-injection", nil, "inline injection spec to apply when creating the sandbox (can be specified multiple times, format: type=,api-key=,base-url=,headers=)") + cmd.Flags().StringArrayVar(&info.Resources, "resource", nil, "resource to mount before sandbox starts (can be specified multiple times, format: type=github_repository,url=,mount-path=,token=; warning: passing tokens via CLI may leak through shell history or process lists)") return cmd } diff --git a/cmd/sandbox_injection_rule.go b/cmd/sandbox_injection_rule.go index 47980e20..1df75536 100644 --- a/cmd/sandbox_injection_rule.go +++ b/cmd/sandbox_injection_rule.go @@ -103,7 +103,11 @@ var injectionRuleCreateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { # Create a Qiniu AI API injection rule qshell sandbox injection-rule create --name qiniu-ai --type qiniu --api-key ak-xxx - qshell sbx ir cr --name qiniu-ai --type qiniu --api-key ak-xxx`, + qshell sbx ir cr --name qiniu-ai --type qiniu --api-key ak-xxx + + # Create a GitHub credential injection rule (token passed via --api-key) + qshell sandbox injection-rule create --name github-default --type github --api-key ghp-xxx + qshell sbx ir cr --name github-default --type github --api-key ghp-xxx`, Run: func(cmd *cobra.Command, args []string) { cfg.CmdCfg.CmdId = docs.SandboxInjectionRuleCreateType if !iqshell.CheckAndLoad(cfg, iqshell.CheckAndLoadInfo{}) { @@ -113,8 +117,8 @@ var injectionRuleCreateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { }, } cmd.Flags().StringVar(&info.Name, "name", "", "rule name (required, unique per user)") - cmd.Flags().StringVar(&info.Type, "type", "", "injection type: openai, anthropic, gemini, qiniu, http") - cmd.Flags().StringVar(&info.APIKey, "api-key", "", "API key for openai/anthropic/gemini/qiniu injection types (warning: passing secrets via CLI may leak through shell history or process lists)") + cmd.Flags().StringVar(&info.Type, "type", "", "injection type: openai, anthropic, gemini, qiniu, github, http") + cmd.Flags().StringVar(&info.APIKey, "api-key", "", "API key for openai/anthropic/gemini/qiniu, or token for github (warning: passing secrets via CLI may leak through shell history or process lists)") cmd.Flags().StringVar(&info.BaseURL, "base-url", "", "override base URL or target base URL for http injection") cmd.Flags().StringVar(&info.Headers, "headers", "", "HTTP headers for custom http injection (comma-separated key=value pairs)") _ = cmd.MarkFlagRequired("name") @@ -143,7 +147,11 @@ var injectionRuleUpdateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { # Update to a Qiniu AI API injection qshell sandbox injection-rule update rule-xxxxxxxxxxxx --type qiniu --api-key ak-new - qshell sbx ir up rule-xxxxxxxxxxxx --type qiniu --api-key ak-new`, + qshell sbx ir up rule-xxxxxxxxxxxx --type qiniu --api-key ak-new + + # Update to a GitHub credential injection (token passed via --api-key) + qshell sandbox injection-rule update rule-xxxxxxxxxxxx --type github --api-key ghp-new + qshell sbx ir up rule-xxxxxxxxxxxx --type github --api-key ghp-new`, Run: func(cmd *cobra.Command, args []string) { cfg.CmdCfg.CmdId = docs.SandboxInjectionRuleUpdateType if !iqshell.CheckAndLoad(cfg, iqshell.CheckAndLoadInfo{}) { @@ -154,8 +162,8 @@ var injectionRuleUpdateCmdBuilder = func(cfg *iqshell.Config) *cobra.Command { }, } cmd.Flags().StringVar(&info.Name, "name", "", "new rule name") - cmd.Flags().StringVar(&info.Type, "type", "", "new injection type: openai, anthropic, gemini, qiniu, http") - cmd.Flags().StringVar(&info.APIKey, "api-key", "", "new API key for openai/anthropic/gemini/qiniu injection types (warning: passing secrets via CLI may leak through shell history or process lists)") + cmd.Flags().StringVar(&info.Type, "type", "", "new injection type: openai, anthropic, gemini, qiniu, github, http") + cmd.Flags().StringVar(&info.APIKey, "api-key", "", "new API key for openai/anthropic/gemini/qiniu, or token for github (warning: passing secrets via CLI may leak through shell history or process lists)") cmd.Flags().StringVar(&info.BaseURL, "base-url", "", "new base URL or target base URL for http injection") cmd.Flags().StringVar(&info.Headers, "headers", "", "new HTTP headers for custom http injection (comma-separated key=value pairs)") return cmd diff --git a/cmd_test/sandbox_injection_rule_test.go b/cmd_test/sandbox_injection_rule_test.go index 5a1da49a..d7493a04 100644 --- a/cmd_test/sandbox_injection_rule_test.go +++ b/cmd_test/sandbox_injection_rule_test.go @@ -35,7 +35,7 @@ func TestSandboxInjectionRuleCreateDocumentWithQiniu(t *testing.T) { testInjectionRuleDocContains( t, []string{"sandbox", "injection-rule", "create"}, - "--type ", + "--type ", "--name", "qiniu-default", "--type", "qiniu", ) diff --git a/docs/sandbox_create.md b/docs/sandbox_create.md index 8d668c1a..887a5d2e 100644 --- a/docs/sandbox_create.md +++ b/docs/sandbox_create.md @@ -7,8 +7,8 @@ # 格式 ``` -qshell sandbox create [template] [-t ] [--detach] [-m ] [-e ...] [--auto-pause] [--injection-rule ...] [--inline-injection ...] -qshell sbx cr [template] [-t ] [--detach] [-m ] [-e ...] [--auto-pause] [--injection-rule ...] [--inline-injection ...] +qshell sandbox create [template] [-t ] [--detach] [-m ] [-e ...] [--auto-pause] [--injection-rule ...] [--inline-injection ...] [--resource ...] +qshell sbx cr [template] [-t ] [--detach] [-m ] [-e ...] [--auto-pause] [--injection-rule ...] [--inline-injection ...] [--resource ...] ``` # 帮助文档 @@ -29,11 +29,18 @@ $ qshell sandbox create --doc - `--auto-pause`:超时后自动暂停沙箱,而不是终止沙箱 - `--injection-rule`:创建沙箱时附加的注入规则 ID,可多次指定 - `--inline-injection`:创建沙箱时附加的内联注入配置,可多次指定,格式为 `type=,api-key=,base-url=,headers=` +- `--resource`:沙箱启动前挂载的资源规约,可多次指定,格式为 `type=github_repository,url=,mount-path=,token=`(`type` 默认为 `github_repository`,`mount-path` 也可写作 `mount`)。注意:通过 CLI 传递 token 可能泄露到 Shell 历史或进程列表 + +资源说明: +- `url` 推荐使用 HTTPS 形式(如 `https://github.com/owner/repo.git`);若 URL 本身包含逗号,当前键值串格式无法正确表达 +- `mount-path` 必须是沙箱内的绝对路径(POSIX),不接受相对路径;同时给出 `mount-path` 与 `mount` 时两者取值必须一致 +- 同一沙箱内多条 `--resource github_repository` 当前必须共用同一 `token`(受 SDK 侧约束) +- `--resource` 与 `--inline-injection type=github` 之间的 token 一致性由平台侧校验,CLI 不做跨参数比较 内联注入说明: -- `type` 支持 `openai`、`anthropic`、`gemini`、`qiniu`、`http` -- `api-key` 可用于 `openai`、`anthropic`、`gemini`、`qiniu` -- `base-url` 可用于覆盖默认目标地址;`type=http` 时必填,`type=qiniu` 默认目标地址为 `api.qnaigc.com` +- `type` 支持 `openai`、`anthropic`、`gemini`、`qiniu`、`github`、`http` +- `api-key` 用于 `openai`、`anthropic`、`gemini`、`qiniu` 的 API Key,以及 `github` 的访问 token(token 仅平台可见,沙箱内不可见明文) +- `base-url` 可用于覆盖默认目标地址;`type=http` 时必填,`type=qiniu` 默认目标地址为 `api.qnaigc.com`;`type=github` 固定匹配 `github.com` / `api.github.com`,不支持配置 - `headers` 仅用于 `type=http`,多个请求头使用分号分隔,例如 `headers=Authorization=Bearer token;X-Env=prod` # 示例 @@ -86,3 +93,19 @@ $ qshell sandbox create my-template \ --inline-injection 'type=http,base-url=https://api.example.com,headers=Authorization=Bearer token;X-Env=prod' $ qshell sbx cr my-template --inline-injection 'type=gemini,api-key=sk-gem' ``` + +9. 创建时附加 GitHub 凭证注入(token 通过 `api-key` 传入) +``` +$ qshell sandbox create my-template --inline-injection 'type=github,api-key=ghp-xxx' +$ qshell sbx cr my-template --inline-injection 'type=github,api-key=ghp-xxx' +``` + +10. 创建时挂载 GitHub 仓库资源(沙箱启动前由平台拉取仓库快照并挂载到指定路径) +``` +$ qshell sandbox create my-template \ + --resource 'type=github_repository,url=https://github.com/owner/repo.git,mount-path=/workspace/repo,token=ghp-xxx' +$ qshell sbx cr my-template \ + --resource 'url=https://github.com/owner/repo.git,mount-path=/workspace/repo,token=ghp-xxx' +``` + +> 同一沙箱内多个 `--resource github_repository` 当前必须共用同一 `token`。 diff --git a/docs/sandbox_injection_rule_create.md b/docs/sandbox_injection_rule_create.md index a4ee7aed..69730f1d 100644 --- a/docs/sandbox_injection_rule_create.md +++ b/docs/sandbox_injection_rule_create.md @@ -4,8 +4,8 @@ # 格式 ```bash -qshell sandbox injection-rule create --name --type [--api-key ] [--base-url ] [--headers ] -qshell sbx ir cr --name --type [--api-key ] [--base-url ] [--headers ] +qshell sandbox injection-rule create --name --type [--api-key ] [--base-url ] [--headers ] +qshell sbx ir cr --name --type [--api-key ] [--base-url ] [--headers ] ``` # 帮助文档 @@ -18,9 +18,9 @@ $ qshell sandbox injection-rule create --doc # 参数 - `--name`:规则名称,必填,同一用户下唯一 -- `--type`:注入类型,必填,支持 `openai`、`anthropic`、`gemini`、`qiniu`、`http` -- `--api-key`:`openai`、`anthropic`、`gemini`、`qiniu` 类型使用的 API Key。注意:通过 CLI 传递密钥可能泄露到 Shell 历史或进程列表 -- `--base-url`:覆盖默认目标地址,或 `http` 类型的目标基础 URL;`qiniu` 默认为 `api.qnaigc.com` +- `--type`:注入类型,必填,支持 `openai`、`anthropic`、`gemini`、`qiniu`、`github`、`http` +- `--api-key`:`openai`、`anthropic`、`gemini`、`qiniu` 类型使用的 API Key,`github` 类型使用的访问 token;`type=github` 时必填。注意:通过 CLI 传递密钥可能泄露到 Shell 历史或进程列表 +- `--base-url`:覆盖默认目标地址,或 `http` 类型的目标基础 URL;`qiniu` 默认为 `api.qnaigc.com`;`github` 类型固定匹配 `github.com` / `api.github.com`,不支持配置 - `--headers`:`http` 类型的请求头,使用逗号分隔的 `key=value` 形式 # 示例 @@ -48,3 +48,9 @@ $ qshell sandbox injection-rule create --name api-auth --type http --base-url ht ```bash $ qshell sandbox injection-rule create --name qiniu-ai --type qiniu --api-key ak-xxx ``` + +创建 GitHub 凭证注入规则(token 通过 `--api-key` 传入,沙箱内不可见明文): + +```bash +$ qshell sandbox injection-rule create --name github-default --type github --api-key ghp-xxx +``` diff --git a/docs/sandbox_injection_rule_update.md b/docs/sandbox_injection_rule_update.md index 977c7d7a..d7d84adf 100644 --- a/docs/sandbox_injection_rule_update.md +++ b/docs/sandbox_injection_rule_update.md @@ -4,8 +4,8 @@ # 格式 ```bash -qshell sandbox injection-rule update [--name ] [--type ] [--api-key ] [--base-url ] [--headers ] -qshell sbx ir up [--name ] [--type ] [--api-key ] [--base-url ] [--headers ] +qshell sandbox injection-rule update [--name ] [--type ] [--api-key ] [--base-url ] [--headers ] +qshell sbx ir up [--name ] [--type ] [--api-key ] [--base-url ] [--headers ] ``` # 帮助文档 @@ -20,7 +20,7 @@ $ qshell sandbox injection-rule update --doc - `ruleID`:注入规则 ID - `--name`:新的规则名称 - `--type`:新的注入类型;当需要更新注入配置时必须指定 -- `--api-key`:新的 API Key;更新注入配置时必须与 `--type` 一同指定。注意:通过 CLI 传递密钥可能泄露到 Shell 历史或进程列表 +- `--api-key`:新的 API Key;更新注入配置时必须与 `--type` 一同指定,且 `type=github` 时必填。注意:通过 CLI 传递密钥可能泄露到 Shell 历史或进程列表 - `--base-url`:新的基础 URL;更新注入配置时必须与 `--type` 一同指定 - `--headers`:新的自定义 HTTP 请求头,使用逗号分隔的 `key=value` 形式;更新时必须与 `--type http` 一同指定 @@ -51,3 +51,9 @@ $ qshell sandbox injection-rule update rule-xxxxxxxxxxxx --type qiniu --api-key ```bash $ qshell sandbox injection-rule update rule-xxxxxxxxxxxx --type http --base-url https://api.example.com --headers "Authorization=Bearer newtoken" ``` + +更新为 GitHub 凭证注入(token 通过 `--api-key` 传入): + +```bash +$ qshell sandbox injection-rule update rule-xxxxxxxxxxxx --type github --api-key ghp-new +``` diff --git a/go.mod b/go.mod index 7d6e0d2d..2c97401c 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/fatih/color v1.18.0 github.com/mitchellh/go-homedir v1.1.0 github.com/muesli/termenv v0.16.0 - github.com/qiniu/go-sdk/v7 v7.26.10 + github.com/qiniu/go-sdk/v7 v7.26.12 github.com/schollz/progressbar/v3 v3.8.6 github.com/spf13/cast v1.3.1 github.com/spf13/cobra v1.1.3 diff --git a/go.sum b/go.sum index c7078aaf..97dfcd62 100644 --- a/go.sum +++ b/go.sum @@ -294,8 +294,10 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/qiniu/go-sdk/v7 v7.26.10 h1:1c2c+grH7b8k5inIDKc95CI8+mAzB5YtfQ/dLOB2nNo= -github.com/qiniu/go-sdk/v7 v7.26.10/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk= +github.com/qiniu/go-sdk/v7 v7.26.11 h1:XVGb5cgqYnNaExCirky+OntL1zvxRxBJuYoWMlnHOuE= +github.com/qiniu/go-sdk/v7 v7.26.11/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk= +github.com/qiniu/go-sdk/v7 v7.26.12 h1:AnWiKjBY62XpULoB/MySKdNmlUo9S9pQB7/0s7QueCo= +github.com/qiniu/go-sdk/v7 v7.26.12/go.mod h1:ri7fGwbio0pRDFr8EK5TUpx0DbnpIMJ2bMSDxGWfCbk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/iqshell/sandbox/injection_rule/operations/helpers.go b/iqshell/sandbox/injection_rule/operations/helpers.go index 8c133881..daa3ff3c 100644 --- a/iqshell/sandbox/injection_rule/operations/helpers.go +++ b/iqshell/sandbox/injection_rule/operations/helpers.go @@ -14,9 +14,15 @@ const ( injectionTypeAnthropic = "anthropic" injectionTypeGemini = "gemini" injectionTypeQiniu = "qiniu" + injectionTypeGithub = "github" injectionTypeHTTP = "http" ) +// githubInjectionTarget 描述 GitHub 注入由平台侧匹配的目标域名集合。 +// 当前平台行为固定为 github.com / api.github.com;若后续 SDK 支持自托管 GitHub Enterprise, +// 需要随 SDK 暴露的常量一起同步更新此处。 +const githubInjectionTarget = "github.com, api.github.com" + type injectionInput struct { Type string APIKey string @@ -34,6 +40,7 @@ func buildInjectionSpec(input injectionInput) (sandbox.InjectionSpec, error) { Anthropic: parts.Anthropic, Gemini: parts.Gemini, Qiniu: parts.Qiniu, + Github: parts.Github, HTTP: parts.HTTP, }, nil } @@ -52,6 +59,8 @@ func formatInjectionType(spec sandbox.InjectionSpec) string { return injectionTypeGemini case spec.Qiniu != nil: return injectionTypeQiniu + case spec.Github != nil: + return injectionTypeGithub case spec.HTTP != nil: return injectionTypeHTTP default: @@ -69,6 +78,8 @@ func formatInjectionTarget(spec sandbox.InjectionSpec) string { return optionalValue(spec.Gemini.BaseURL, "generativelanguage.googleapis.com") case spec.Qiniu != nil: return optionalValue(spec.Qiniu.BaseURL, "api.qnaigc.com") + case spec.Github != nil: + return githubInjectionTarget case spec.HTTP != nil: return spec.HTTP.BaseURL default: @@ -98,6 +109,8 @@ func hasAPIKey(spec sandbox.InjectionSpec) bool { return spec.Gemini.APIKey != nil && strings.TrimSpace(*spec.Gemini.APIKey) != "" case spec.Qiniu != nil: return spec.Qiniu.APIKey != nil && strings.TrimSpace(*spec.Qiniu.APIKey) != "" + case spec.Github != nil: + return spec.Github.Token != nil && strings.TrimSpace(*spec.Github.Token) != "" default: return false } diff --git a/iqshell/sandbox/injection_rule/operations/helpers_test.go b/iqshell/sandbox/injection_rule/operations/helpers_test.go index a3b74e94..ecd9c7a5 100644 --- a/iqshell/sandbox/injection_rule/operations/helpers_test.go +++ b/iqshell/sandbox/injection_rule/operations/helpers_test.go @@ -131,6 +131,49 @@ func TestFormatInjectionTargetQiniuDefault(t *testing.T) { } } +func TestFormatInjectionSummaryGithub(t *testing.T) { + token := "ghp-token" + spec := sandbox.InjectionSpec{ + Github: &sandbox.GithubInjection{Token: &token}, + } + + if got := formatInjectionType(spec); got != "github" { + t.Fatalf("formatInjectionType() = %q, want %q", got, "github") + } + if got := formatInjectionTarget(spec); got != "github.com, api.github.com" { + t.Fatalf("formatInjectionTarget() = %q, want %q", got, "github.com, api.github.com") + } + if got := formatInjectionHeaders(spec); got != "-" { + t.Fatalf("formatInjectionHeaders() = %q, want %q", got, "-") + } + if !hasAPIKey(spec) { + t.Fatal("hasAPIKey() = false, want true for github with token") + } +} + +func TestBuildInjectionSpecGithub(t *testing.T) { + spec, err := buildInjectionSpec(injectionInput{ + Type: injectionTypeGithub, + APIKey: "ghp-token", + }) + if err != nil { + t.Fatalf("buildInjectionSpec(github) error = %v", err) + } + if spec.Github == nil { + t.Fatal("expected Github injection to be set") + } + if spec.Github.Token == nil || *spec.Github.Token != "ghp-token" { + t.Fatalf("Github token = %v, want %q", spec.Github.Token, "ghp-token") + } +} + +// 空 token 的 github 规则无意义,需要在 CLI 层提前拒绝 +func TestBuildInjectionSpecGithubRequiresToken(t *testing.T) { + if _, err := buildInjectionSpec(injectionInput{Type: injectionTypeGithub}); err == nil { + t.Fatal("expected buildInjectionSpec(github) without api-key to fail") + } +} + func TestShouldUpdateInjection(t *testing.T) { if shouldUpdateInjection(injectionInput{}) { t.Fatal("shouldUpdateInjection() = true, want false") diff --git a/iqshell/sandbox/sandbox/operations/create.go b/iqshell/sandbox/sandbox/operations/create.go index d8806322..02e2a472 100644 --- a/iqshell/sandbox/sandbox/operations/create.go +++ b/iqshell/sandbox/sandbox/operations/create.go @@ -3,6 +3,7 @@ package operations import ( "context" "fmt" + "path" "strings" "github.com/qiniu/go-sdk/v7/sandbox" @@ -20,6 +21,8 @@ type CreateInfo struct { AutoPause bool InjectionRuleID []string InlineInjection []string + // Resources 沙箱启动前挂载的资源规约(如 GitHub 仓库),格式参见 parseSandboxResource + Resources []string } // Create creates a new sandbox and connects to its terminal. @@ -65,6 +68,14 @@ func Create(info CreateInfo) { if len(injections) > 0 { params.Injections = &injections } + resources, err := buildSandboxResources(info.Resources) + if err != nil { + sbClient.PrintError("%v", err) + return + } + if len(resources) > 0 { + params.Resources = &resources + } fmt.Printf("Creating sandbox from template %s...\n", info.TemplateID) sb, _, err := client.CreateAndWait(ctx, params) @@ -135,6 +146,7 @@ func parseInlineSandboxInjection(spec string) (sandbox.SandboxInjectionSpec, err Anthropic: parts.Anthropic, Gemini: parts.Gemini, Qiniu: parts.Qiniu, + Github: parts.Github, HTTP: parts.HTTP, }, nil } @@ -170,6 +182,85 @@ func parseInlineInjectionFields(spec string) map[string]string { return fields } +// buildSandboxResources 把命令行传入的 --resource 规约转换为 SDK 的资源列表。 +func buildSandboxResources(resourceSpecs []string) ([]sandbox.SandboxResourceSpec, error) { + if len(resourceSpecs) == 0 { + return nil, nil + } + resources := make([]sandbox.SandboxResourceSpec, 0, len(resourceSpecs)) + // 同一沙箱内多个 GitHub 仓库资源当前必须共用同一 token(go-sdk 注释明示约束); + // 提前在 CLI 层校验,避免等到平台克隆阶段才返回不易理解的错误。 + var seenToken string + for _, spec := range resourceSpecs { + resource, err := parseSandboxResource(spec) + if err != nil { + return nil, err + } + if gr := resource.GitRepository; gr != nil { + if gr.AuthorizationToken == nil { + return nil, fmt.Errorf("invalid resource spec %q: token is required for github_repository", spec) + } + switch token := *gr.AuthorizationToken; { + case seenToken == "": + seenToken = token + case token != seenToken: + return nil, fmt.Errorf("inconsistent --resource tokens: a sandbox can carry only one GitHub token across all repository resources") + } + } + resources = append(resources, resource) + } + return resources, nil +} + +// parseSandboxResource 解析单条 --resource 规约。 +// 支持格式:type=github_repository,url=,mount-path=,token= +func parseSandboxResource(spec string) (sandbox.SandboxResourceSpec, error) { + fields := sbClient.ParseMetadataMap(spec) + + typ := strings.ToLower(fields["type"]) + if typ == "" { + typ = string(sandbox.GitRepositoryTypeGithub) + } + + switch typ { + case string(sandbox.GitRepositoryTypeGithub): + url := fields["url"] + if url == "" { + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: url is required for github_repository", spec) + } + mountPath := fields["mount-path"] + mountAlias := fields["mount"] + // 同时给出 mount-path= 与 mount= 且取值不一致时直接报错,避免静默忽略其中一项造成误解 + if mountPath != "" && mountAlias != "" && mountPath != mountAlias { + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: mount-path %q and mount %q conflict, specify only one", spec, mountPath, mountAlias) + } + if mountPath == "" { + // 兼容 mount= 简写 + mountPath = mountAlias + } + if mountPath == "" { + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: mount-path is required for github_repository", spec) + } + // 沙箱内部使用 POSIX 路径;用 path.IsAbs 而非 filepath.IsAbs,避免 Windows 主机上把 /workspace 误判为相对 + if !path.IsAbs(mountPath) { + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: mount-path %q must be an absolute path", spec, mountPath) + } + token := fields["token"] + if token == "" { + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: token is required for github_repository", spec) + } + res := &sandbox.GitRepositoryResource{ + Type: sandbox.GitRepositoryTypeGithub, + URL: url, + MountPath: mountPath, + AuthorizationToken: &token, + } + return sandbox.SandboxResourceSpec{GitRepository: res}, nil + default: + return sandbox.SandboxResourceSpec{}, fmt.Errorf("invalid resource spec %q: unsupported type %q (supported: github_repository)", spec, typ) + } +} + func parseInlineHeaders(raw string) map[string]string { m := make(map[string]string) if raw == "" { diff --git a/iqshell/sandbox/sandbox/operations/create_test.go b/iqshell/sandbox/sandbox/operations/create_test.go index 1bd73d76..f6c0fa39 100644 --- a/iqshell/sandbox/sandbox/operations/create_test.go +++ b/iqshell/sandbox/sandbox/operations/create_test.go @@ -1,6 +1,10 @@ package operations -import "testing" +import ( + "testing" + + "github.com/qiniu/go-sdk/v7/sandbox" +) func TestBuildSandboxInjections_Empty(t *testing.T) { injections, err := buildSandboxInjections(nil, nil) @@ -155,3 +159,140 @@ func TestParseInlineHeaders_CommaFallback(t *testing.T) { t.Fatalf("headers = %v, want parsed headers", headers) } } + +// === buildSandboxResources tests === + +func TestBuildSandboxResources_Empty(t *testing.T) { + resources, err := buildSandboxResources(nil) + if err != nil { + t.Fatalf("buildSandboxResources() error = %v", err) + } + if resources != nil { + t.Fatalf("buildSandboxResources() = %v, want nil", resources) + } +} + +func TestBuildSandboxResources_GithubRepository(t *testing.T) { + resources, err := buildSandboxResources([]string{ + "type=github_repository,url=https://github.com/owner/repo.git,mount-path=/workspace/repo,token=ghp-xxx", + }) + if err != nil { + t.Fatalf("buildSandboxResources() error = %v", err) + } + if len(resources) != 1 { + t.Fatalf("buildSandboxResources() len = %d, want 1", len(resources)) + } + got := resources[0].GitRepository + if got == nil { + t.Fatalf("resource = %+v, want GitRepository set", resources[0]) + } + if got.Type != sandbox.GitRepositoryTypeGithub { + t.Fatalf("type = %q, want %q", got.Type, sandbox.GitRepositoryTypeGithub) + } + if got.URL != "https://github.com/owner/repo.git" { + t.Fatalf("url = %q, want %q", got.URL, "https://github.com/owner/repo.git") + } + if got.MountPath != "/workspace/repo" { + t.Fatalf("mount path = %q, want %q", got.MountPath, "/workspace/repo") + } + if got.AuthorizationToken == nil || *got.AuthorizationToken != "ghp-xxx" { + t.Fatalf("token = %v, want ghp-xxx", got.AuthorizationToken) + } +} + +func TestBuildSandboxResources_DefaultsTypeAndAcceptsMountAlias(t *testing.T) { + resources, err := buildSandboxResources([]string{ + "url=https://github.com/owner/repo.git,mount=/workspace/repo,token=ghp-xxx", + }) + if err != nil { + t.Fatalf("buildSandboxResources() error = %v", err) + } + got := resources[0].GitRepository + if got == nil { + t.Fatal("resource GitRepository = nil, want set when type omitted") + } + if got.Type != sandbox.GitRepositoryTypeGithub { + t.Fatalf("type defaulted = %q, want %q", got.Type, sandbox.GitRepositoryTypeGithub) + } + if got.MountPath != "/workspace/repo" { + t.Fatalf("mount path via mount= alias = %q, want /workspace/repo", got.MountPath) + } + if got.AuthorizationToken == nil || *got.AuthorizationToken != "ghp-xxx" { + t.Fatalf("token = %v, want ghp-xxx", got.AuthorizationToken) + } +} + +func TestBuildSandboxResources_RejectsMissingURL(t *testing.T) { + if _, err := buildSandboxResources([]string{"type=github_repository,mount-path=/workspace"}); err == nil { + t.Fatal("expected missing url to fail") + } +} + +func TestBuildSandboxResources_RejectsMissingMountPath(t *testing.T) { + if _, err := buildSandboxResources([]string{"type=github_repository,url=https://github.com/owner/repo.git,token=ghp-xxx"}); err == nil { + t.Fatal("expected missing mount-path to fail") + } +} + +func TestBuildSandboxResources_RejectsMissingToken(t *testing.T) { + if _, err := buildSandboxResources([]string{"type=github_repository,url=https://github.com/owner/repo.git,mount-path=/workspace"}); err == nil { + t.Fatal("expected missing token to fail") + } +} + +func TestBuildSandboxResources_RejectsRelativeMountPath(t *testing.T) { + if _, err := buildSandboxResources([]string{"url=https://github.com/owner/repo.git,mount-path=workspace/repo,token=ghp-xxx"}); err == nil { + t.Fatal("expected relative mount-path to fail") + } +} + +func TestBuildSandboxResources_RejectsUnsupportedType(t *testing.T) { + if _, err := buildSandboxResources([]string{"type=gitlab_repository,url=https://gitlab.com/owner/repo.git,mount-path=/workspace,token=ghp-xxx"}); err == nil { + t.Fatal("expected unsupported resource type to fail") + } +} + +func TestBuildSandboxResources_Multiple(t *testing.T) { + resources, err := buildSandboxResources([]string{ + "url=https://github.com/owner/a.git,mount-path=/workspace/a,token=ghp-shared", + "url=https://github.com/owner/b.git,mount-path=/workspace/b,token=ghp-shared", + }) + if err != nil { + t.Fatalf("buildSandboxResources() error = %v", err) + } + if len(resources) != 2 { + t.Fatalf("buildSandboxResources() len = %d, want 2", len(resources)) + } +} + +func TestBuildSandboxResources_RejectsConflictingTokens(t *testing.T) { + _, err := buildSandboxResources([]string{ + "url=https://github.com/owner/a.git,mount-path=/workspace/a,token=ghp-A", + "url=https://github.com/owner/b.git,mount-path=/workspace/b,token=ghp-B", + }) + if err == nil { + t.Fatal("expected conflicting tokens across --resource to fail") + } +} + +func TestBuildSandboxResources_RejectsConflictingMountAliases(t *testing.T) { + // 同时给出 mount-path 与 mount 且不一致时,不能静默丢弃任意一项 + if _, err := buildSandboxResources([]string{ + "url=https://github.com/owner/a.git,mount-path=/workspace/a,mount=/workspace/b,token=ghp", + }); err == nil { + t.Fatal("expected conflicting mount-path and mount aliases to fail") + } +} + +func TestBuildSandboxResources_AcceptsAgreeingMountAliases(t *testing.T) { + // 两个别名取值相同时视为冗余,仍接受 + resources, err := buildSandboxResources([]string{ + "url=https://github.com/owner/a.git,mount-path=/workspace/a,mount=/workspace/a,token=ghp", + }) + if err != nil { + t.Fatalf("buildSandboxResources() error = %v", err) + } + if got := resources[0].GitRepository.MountPath; got != "/workspace/a" { + t.Fatalf("mount path = %q, want /workspace/a", got) + } +} diff --git a/iqshell/sandbox/utils.go b/iqshell/sandbox/utils.go index b46a75c7..b130b11c 100644 --- a/iqshell/sandbox/utils.go +++ b/iqshell/sandbox/utils.go @@ -98,10 +98,11 @@ type InjectionParts struct { Anthropic *sdkSandbox.AnthropicInjection Gemini *sdkSandbox.GeminiInjection Qiniu *sdkSandbox.QiniuInjection + Github *sdkSandbox.GithubInjection HTTP *sdkSandbox.HTTPInjection } -const supportedInjectionTypes = "openai, anthropic, gemini, qiniu, http" +const supportedInjectionTypes = "openai, anthropic, gemini, qiniu, github, http" // BuildInjectionParts builds a provider-specific injection payload from the given inputs. func BuildInjectionParts(typ, apiKey, baseURL string, headers map[string]string) (InjectionParts, error) { @@ -138,6 +139,24 @@ func BuildInjectionParts(typ, apiKey, baseURL string, headers map[string]string) BaseURL: optionalString(validatedBaseURL), }, }, nil + case "github": + // GitHub 注入仅接受 token(经 api-key 字段承载),目标固定为 github.com / api.github.com; + // 显式拒绝 base-url / headers,避免 typo 看起来配置成功却被静默丢弃 + if strings.TrimSpace(baseURL) != "" { + return InjectionParts{}, fmt.Errorf("base URL is not supported for injection type github; target is fixed to github.com / api.github.com") + } + if len(headers) > 0 { + return InjectionParts{}, fmt.Errorf("headers are not supported for injection type github") + } + // GitHub 注入只有 token 这一项配置;空 token 等同于无效注入,提前在 CLI 层报错 + if strings.TrimSpace(apiKey) == "" { + return InjectionParts{}, fmt.Errorf("api-key (GitHub token) is required for injection type github") + } + return InjectionParts{ + Github: &sdkSandbox.GithubInjection{ + Token: optionalString(apiKey), + }, + }, nil case "http": validatedBaseURL, err := validateBaseURL(baseURL, true) if err != nil { diff --git a/iqshell/sandbox/utils_test.go b/iqshell/sandbox/utils_test.go index 2238c482..67122578 100644 --- a/iqshell/sandbox/utils_test.go +++ b/iqshell/sandbox/utils_test.go @@ -438,3 +438,37 @@ func TestBuildInjectionParts_QiniuEmptyOptionalFields(t *testing.T) { t.Fatalf("qiniu optional fields = %+v, want nil pointers", parts.Qiniu) } } + +func TestBuildInjectionParts_Github(t *testing.T) { + parts, err := BuildInjectionParts("github", " ghp-token ", "", nil) + if err != nil { + t.Fatalf("BuildInjectionParts(github) error = %v", err) + } + if parts.Github == nil { + t.Fatal("BuildInjectionParts(github) did not build github injection") + } + if parts.Github.Token == nil || *parts.Github.Token != "ghp-token" { + t.Fatalf("github token = %v, want ghp-token", parts.Github.Token) + } +} + +func TestBuildInjectionParts_GithubEmptyToken(t *testing.T) { + if _, err := BuildInjectionParts("github", "", "", nil); err == nil { + t.Fatal("expected BuildInjectionParts(github) without token to fail") + } + if _, err := BuildInjectionParts("github", " ", "", nil); err == nil { + t.Fatal("expected BuildInjectionParts(github) with whitespace-only token to fail") + } +} + +func TestBuildInjectionParts_GithubRejectsBaseURL(t *testing.T) { + if _, err := BuildInjectionParts("github", "ghp-x", "https://api.github.com", nil); err == nil { + t.Fatal("expected BuildInjectionParts(github, base-url) to fail") + } +} + +func TestBuildInjectionParts_GithubRejectsHeaders(t *testing.T) { + if _, err := BuildInjectionParts("github", "ghp-x", "", map[string]string{"X": "y"}); err == nil { + t.Fatal("expected BuildInjectionParts(github, headers) to fail") + } +}