@hadooppei/hwcode 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/hwcode.ts +35 -2
- package/.pi/extensions/model-providers.ts +1 -2
- package/.pi/extensions/workflows/cloud/activation.ts +235 -0
- package/.pi/extensions/workflows/cloud/commands.ts +96 -0
- package/.pi/extensions/workflows/cloud/events.ts +58 -0
- package/.pi/extensions/workflows/cloud/index.ts +17 -0
- package/.pi/extensions/workflows/cloud/provider-tools.ts +127 -0
- package/.pi/extensions/workflows/cloud/runner-tools.ts +129 -0
- package/.pi/extensions/workflows/cloud/runtime.ts +97 -0
- package/.pi/extensions/workflows/cloud/shared.ts +213 -0
- package/.pi/extensions/workflows/cloud/terraform-tools.ts +126 -0
- package/.pi/extensions/workflows/vibe-sdd.ts +300 -0
- package/.pi/extensions/workflows.ts +6 -253
- package/.pi/lib/runtime/config.ts +3 -7
- package/.pi/lib/runtime/defaults.ts +20 -0
- package/.pi/lib/runtime/paths.ts +68 -0
- package/.pi/lib/runtime/session-state.ts +0 -34
- package/.pi/lib/workflow-guard.ts +8 -1
- package/.pi/lib/{cloud → workflows/cloud}/adapters.ts +7 -6
- package/.pi/lib/workflows/cloud/bundles.ts +358 -0
- package/.pi/lib/workflows/cloud/execution.ts +28 -0
- package/.pi/lib/{cloud → workflows/cloud}/process.ts +5 -3
- package/.pi/lib/workflows/cloud/remote/bootstrap.ts +23 -0
- package/.pi/lib/workflows/cloud/remote/connect.ts +83 -0
- package/.pi/lib/workflows/cloud/remote/host-key.ts +35 -0
- package/.pi/lib/workflows/cloud/remote/profiles.ts +100 -0
- package/.pi/lib/workflows/cloud/remote/ssh-transport.ts +104 -0
- package/.pi/lib/workflows/cloud/remote/workspace.ts +109 -0
- package/.pi/lib/workflows/cloud/template-save.ts +108 -0
- package/.pi/lib/workflows/cloud/templates.ts +256 -0
- package/.pi/lib/workflows/cloud/terraform/plan.ts +109 -0
- package/.pi/lib/workflows/cloud/terraform/policy.ts +36 -0
- package/.pi/lib/workflows/cloud/terraform/runner.ts +64 -0
- package/.pi/lib/{cloud-vault.ts → workflows/cloud/vault.ts} +26 -24
- package/.pi/lib/workflows/cloud/workspace.ts +37 -0
- package/.pi/lib/workflows/sdd.ts +11 -0
- package/.pi/lib/workflows/state.ts +165 -1
- package/.pi/lib/working-directory.ts +0 -58
- package/.pi/lib/workspace/access-policy.ts +2 -2
- package/.pi/skills/hwcode-cloud/SKILL.md +32 -1
- package/.pi/skills/hwcode-sdd/SKILL.md +2 -0
- package/README.md +52 -12
- package/bin/hwcode.js +2 -6
- package/package.json +8 -3
- package/.pi/extensions/cloud.ts +0 -587
- package/.pi/lib/cloud/templates.ts +0 -148
- /package/.pi/lib/{cloud-providers.ts → workflows/cloud/providers.ts} +0 -0
package/.pi/extensions/hwcode.ts
CHANGED
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { WORKFLOW_STATE_TYPE, activeWorkflow, updateWorkflowState, workflowLabel, type WorkflowState } from "../lib/workflows/state.ts";
|
|
2
3
|
|
|
3
4
|
export default function hwcodeExtension(pi: ExtensionAPI) {
|
|
4
5
|
pi.registerCommand("hwcode", {
|
|
5
|
-
description: "Show
|
|
6
|
+
description: "Show, complete, or cancel the active HWCode workflow",
|
|
6
7
|
handler: async (_args, ctx) => {
|
|
7
|
-
ctx.
|
|
8
|
+
const state = activeWorkflow(ctx.sessionManager.getEntries());
|
|
9
|
+
if (!state) {
|
|
10
|
+
ctx.ui.notify("HWCode profile is active; no workflow is running in this session.", "info");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const choice = await ctx.ui.select(
|
|
14
|
+
`${workflowLabel(state.mode)} · ${state.phase} · ${state.root}`,
|
|
15
|
+
["查看状态", "标记完成", "取消 workflow", "返回"],
|
|
16
|
+
);
|
|
17
|
+
if (!choice || choice === "返回") return;
|
|
18
|
+
if (choice === "查看状态") {
|
|
19
|
+
const cloud = state.mode === "cloud" && state.details
|
|
20
|
+
? ` Successful steps: ${state.details.successfulSteps.length}; failed strategies: ${state.details.failedApproaches.length}; active created resources: ${(state.details.resources ?? []).filter((resource) => resource.ownership === "workflow-created" && resource.status === "active").length}.`
|
|
21
|
+
: "";
|
|
22
|
+
const sdd = state.mode === "sdd" && state.sdd ? ` SDD phase: ${state.sdd.phase}; approvals: ${state.sdd.approvals.length}.` : "";
|
|
23
|
+
ctx.ui.notify(`${workflowLabel(state.mode)} is active. Root: ${state.root}; phase: ${state.phase}.${cloud}${sdd}`, "info");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const status = choice === "标记完成" ? "completed" : "cancelled";
|
|
27
|
+
if (status === "completed" && state.mode === "sdd" && state.sdd?.phase !== "verification") {
|
|
28
|
+
ctx.ui.notify(`SDD 当前处于 ${state.sdd?.phase ?? "unknown"},只有完成 verification 阶段后才能标记完成;如需提前退出请选择取消 workflow。`, "warning");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const activeCreatedResources = state.mode === "cloud" ? (state.details?.resources ?? []).filter((resource) => resource.ownership === "workflow-created" && resource.status === "active") : [];
|
|
32
|
+
const warning = activeCreatedResources.length > 0
|
|
33
|
+
? `仍有 ${activeCreatedResources.length} 个 workflow 创建的资源处于 active:\n${activeCreatedResources.slice(0, 10).map((resource) => `• ${resource.type} ${resource.id} (${resource.region})`).join("\n")}\n\n结束不会自动删除它们。请确认这些资源需要保留,或先完成逐项确认的清理。`
|
|
34
|
+
: state.mode === "cloud" && state.details?.runnerPreference === "automatic"
|
|
35
|
+
? "该 Cloud workflow 请求过临时 Runner,但资源清单中没有可确认的活动记录。结束前请确认 Runner 创建是否成功以及是否已经清理。"
|
|
36
|
+
: "结束后本 session 将解除 workflow 目录与执行限制。";
|
|
37
|
+
if (!await ctx.ui.confirm(status === "completed" ? "确认 workflow 已完成?" : "确认取消 workflow?", warning)) return;
|
|
38
|
+
const updated = updateWorkflowState(state, { status, phase: status, reason: status === "completed" ? "user confirmed completion" : "user cancelled workflow" });
|
|
39
|
+
pi.appendEntry<WorkflowState>(WORKFLOW_STATE_TYPE, updated);
|
|
40
|
+
ctx.ui.notify(`${workflowLabel(state.mode)} ${status}.`, "info");
|
|
8
41
|
},
|
|
9
42
|
});
|
|
10
43
|
}
|
|
@@ -212,7 +212,6 @@ function registerStaticProvider(
|
|
|
212
212
|
baseUrl,
|
|
213
213
|
api: "openai-completions",
|
|
214
214
|
apiKey,
|
|
215
|
-
compat: compatibility(),
|
|
216
215
|
models: (config.models ?? []).map((model) => {
|
|
217
216
|
const contextWindow = resolveContextWindow(
|
|
218
217
|
model.contextWindow ?? config.modelDefaults?.contextWindow,
|
|
@@ -372,7 +371,7 @@ function createLoginProvider(
|
|
|
372
371
|
update: () => { models = refreshed; },
|
|
373
372
|
});
|
|
374
373
|
},
|
|
375
|
-
stream: (model, context, options) => stream(model, context, options),
|
|
374
|
+
stream: (model, context, options) => stream(model, context, options as Parameters<typeof stream>[2]),
|
|
376
375
|
streamSimple: (model, context, options) => streamSimple(model, context, options),
|
|
377
376
|
};
|
|
378
377
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { modelConfigurationIssue } from "../../../lib/models/readiness.ts";
|
|
8
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
9
|
+
import { remoteRunnerPaths, userRuntimePaths } from "../../../lib/runtime/paths.ts";
|
|
10
|
+
import { canonicalizeWorkspaceRoot } from "../../../lib/workflow-guard.ts";
|
|
11
|
+
import { getWorkingDirectory } from "../../../lib/working-directory.ts";
|
|
12
|
+
import { checkProviderCli, formatValidationFailure, validateCloudCredentials } from "../../../lib/workflows/cloud/adapters.ts";
|
|
13
|
+
import { cloudDeploymentTemplateSource, listCloudDeploymentTemplates, materializeCloudDeploymentTemplate, type CloudDeploymentTemplate } from "../../../lib/workflows/cloud/bundles.ts";
|
|
14
|
+
import { CLOUD_PROVIDERS, getCloudProvider, inaccessibleCloudCliMessage, missingCloudCliMessage, type CloudCredentials, type CloudVendorId } from "../../../lib/workflows/cloud/providers.ts";
|
|
15
|
+
import { connectRemoteTarget } from "../../../lib/workflows/cloud/remote/connect.ts";
|
|
16
|
+
import { remoteTargetLabel, remoteTargetSummary, type RemoteTargetProfile } from "../../../lib/workflows/cloud/remote/profiles.ts";
|
|
17
|
+
import { cloudPromptTemplateSource, expandCloudPromptTemplate, listCloudPromptTemplates, type CloudPromptTemplate } from "../../../lib/workflows/cloud/templates.ts";
|
|
18
|
+
import {
|
|
19
|
+
cloudCredentialProfileLabel, defaultCloudVaultPath, listCloudCredentialProfiles,
|
|
20
|
+
listRemoteTargetProfiles, saveCloudCredentialProfile, saveRemoteTargetProfile,
|
|
21
|
+
writeCloudVault, type CloudCredentialProfile,
|
|
22
|
+
} from "../../../lib/workflows/cloud/vault.ts";
|
|
23
|
+
import { createCloudRunWorkspace } from "../../../lib/workflows/cloud/workspace.ts";
|
|
24
|
+
import { activeWorkflow, createCloudWorkflowState, type CloudWorkflowDetails, type WorkflowState } from "../../../lib/workflows/state.ts";
|
|
25
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
26
|
+
import {
|
|
27
|
+
activationPrompt, cloudDetails, collectCredentials, formatTemplateTime, notify,
|
|
28
|
+
remoteTrustInteraction, restoreCloudWorkflow, restoreLegacyCloudWorkflow, unlockVault,
|
|
29
|
+
} from "./shared.ts";
|
|
30
|
+
|
|
31
|
+
type RunnerSelection =
|
|
32
|
+
| { kind: "cancel" }
|
|
33
|
+
| { kind: "automatic" }
|
|
34
|
+
| { kind: "deferred" }
|
|
35
|
+
| { kind: "profile"; profile: RemoteTargetProfile };
|
|
36
|
+
|
|
37
|
+
async function chooseRunner(ctx: ExtensionCommandContext, payload: ReturnType<typeof import("../../../lib/workflows/cloud/vault.ts").createEmptyVault>, vendor: CloudVendorId, root: string, region: string): Promise<RunnerSelection> {
|
|
38
|
+
const existing = listRemoteTargetProfiles(payload).filter((profile) => profile.vendor === vendor);
|
|
39
|
+
const labels = existing.map(remoteTargetLabel);
|
|
40
|
+
const automatic = "自动创建临时 Terraform Runner(推荐)";
|
|
41
|
+
const connect = "连接已有 SSH 主机";
|
|
42
|
+
const choice = await ctx.ui.select("Terraform Runner 来源", [automatic, ...labels, connect, "暂不使用 Terraform Runner", "取消"]);
|
|
43
|
+
if (!choice || choice === "取消") return { kind: "cancel" };
|
|
44
|
+
if (choice === automatic) return { kind: "automatic" };
|
|
45
|
+
if (choice === "暂不使用 Terraform Runner") return { kind: "deferred" };
|
|
46
|
+
const index = labels.indexOf(choice);
|
|
47
|
+
if (index >= 0) return { kind: "profile", profile: existing[index]! };
|
|
48
|
+
const home = homedir();
|
|
49
|
+
const defaultKey = CLOUD_RUNTIME_DEFAULTS.runner.keyCandidates.map((file) => resolve(home, ".ssh", file)).find(existsSync)
|
|
50
|
+
?? resolve(home, ".ssh", CLOUD_RUNTIME_DEFAULTS.runner.keyCandidates[0]!);
|
|
51
|
+
const host = (await ctx.ui.input("Runner 公网地址或 DNS", "例如 203.0.113.10"))?.trim();
|
|
52
|
+
const user = (await ctx.ui.input("SSH 用户", CLOUD_RUNTIME_DEFAULTS.runner.defaultUser))?.trim();
|
|
53
|
+
if (!host || !user) { notify(ctx, "SSH 地址或用户为空,未保存 Runner。", "warning"); return { kind: "cancel" }; }
|
|
54
|
+
const identity = await ctx.ui.select("Runner 云身份", ["已配置实例角色/Agency(可执行 Terraform)", "仅 SSH(只连接,不允许云部署)"]);
|
|
55
|
+
if (!identity) return { kind: "cancel" };
|
|
56
|
+
try {
|
|
57
|
+
return { kind: "profile", profile: await connectRemoteTarget({
|
|
58
|
+
id: `runner-${vendor}-${Date.now()}`,
|
|
59
|
+
name: `${vendor}-ssh-${host.replace(/[^a-z0-9]+/giu, "-").slice(0, 32)}`,
|
|
60
|
+
vendor, region: region || "default", host,
|
|
61
|
+
port: CLOUD_RUNTIME_DEFAULTS.runner.defaultPort, user, keyPath: defaultKey,
|
|
62
|
+
knownHostsPath: userRuntimePaths(home).cloudKnownHosts,
|
|
63
|
+
remoteRoot: remoteRunnerPaths(user).root,
|
|
64
|
+
identityType: identity.startsWith("已配置") ? "agency" : "ssh-only",
|
|
65
|
+
}, root, remoteTrustInteraction(ctx)) };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
notify(ctx, `Runner 配置无效:${error instanceof Error ? error.message : String(error)}`, "error");
|
|
68
|
+
return { kind: "cancel" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function ensureConversationModel(ctx: ExtensionCommandContext): Promise<boolean> {
|
|
73
|
+
let environment: Record<string, string | undefined> = {};
|
|
74
|
+
if (ctx.model) {
|
|
75
|
+
try { environment = (await ctx.modelRegistry.getProviderAuth(ctx.model.provider))?.env ?? {}; }
|
|
76
|
+
catch { /* The readiness check below reports the actionable configuration error. */ }
|
|
77
|
+
}
|
|
78
|
+
const issue = modelConfigurationIssue(ctx.model, environment);
|
|
79
|
+
if (!issue) return true;
|
|
80
|
+
notify(ctx, `${issue}。HWCode 不会替你选择或覆盖默认模型;请先使用 Pi 原生 /model 切换模型,或补全该 provider 的 endpoint 配置。`, "error");
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function choosePromptTemplate(ctx: ExtensionCommandContext): Promise<CloudPromptTemplate | undefined> {
|
|
85
|
+
const templates = listCloudPromptTemplates();
|
|
86
|
+
if (templates.length === 0) { notify(ctx, "还没有本地 Cloud Prompt Template。成功执行任务后使用 /hwcode-cloud-save-template 保存。", "warning"); return undefined; }
|
|
87
|
+
const labels = templates.map((template) => `${template.name} · ${formatTemplateTime(template.updatedAt)}`);
|
|
88
|
+
const selected = await ctx.ui.select("选择已验证的 Cloud Prompt Template", labels);
|
|
89
|
+
const index = selected ? labels.indexOf(selected) : -1;
|
|
90
|
+
return index >= 0 ? templates[index] : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function chooseDeploymentTemplate(ctx: ExtensionCommandContext): Promise<CloudDeploymentTemplate | undefined> {
|
|
94
|
+
const templates = listCloudDeploymentTemplates();
|
|
95
|
+
if (templates.length === 0) { notify(ctx, "还没有 Terraform Deployment Template。成功 apply 后使用 /hwcode-cloud-save-template 保存。", "warning"); return undefined; }
|
|
96
|
+
const labels = templates.map((template) => `${template.manifest.name} · ${template.manifest.vendor} · ${formatTemplateTime(template.manifest.updatedAt)}`);
|
|
97
|
+
const selected = await ctx.ui.select("选择 Terraform Deployment Template", labels);
|
|
98
|
+
const index = selected ? labels.indexOf(selected) : -1;
|
|
99
|
+
return index >= 0 ? templates[index] : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function activateCloudWorkflow(runtime: CloudExtensionRuntime, args: string, ctx: ExtensionCommandContext, initialTemplate?: CloudPromptTemplate, initialBundle?: CloudDeploymentTemplate): Promise<void> {
|
|
103
|
+
if (ctx.mode !== "tui") { notify(ctx, "HWCode Cloud 要求在交互式 TUI 中启动,以安全遮罩凭据输入。", "error"); return; }
|
|
104
|
+
if (!ctx.isIdle()) { notify(ctx, "请等待当前响应完成后再启动 HWCode Cloud。", "warning"); return; }
|
|
105
|
+
if (!(await ensureConversationModel(ctx))) return;
|
|
106
|
+
|
|
107
|
+
const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
|
|
108
|
+
const currentWorkflow = activeWorkflow(ctx.sessionManager.getEntries());
|
|
109
|
+
if (currentWorkflow && currentWorkflow.mode !== "cloud") {
|
|
110
|
+
notify(ctx, `当前会话已有 ${currentWorkflow.mode} workflow。请新建 session 后再启动 HWCode Cloud。`, "warning");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const storedState = restoreCloudWorkflow(ctx) ?? restoreLegacyCloudWorkflow(ctx);
|
|
114
|
+
let resumedState: WorkflowState | undefined;
|
|
115
|
+
if (storedState && storedState.root === root && !cloudDetails(storedState)?.terminalFailure) {
|
|
116
|
+
const storedDetails = cloudDetails(storedState)!;
|
|
117
|
+
const choice = await ctx.ui.select("发现当前会话中未完成的 HWCode Cloud workflow", [
|
|
118
|
+
`恢复 ${getCloudProvider(storedDetails.vendor).label}:${storedDetails.request.slice(0, 60)}`,
|
|
119
|
+
"开始新的 Cloud workflow", "取消",
|
|
120
|
+
]);
|
|
121
|
+
if (!choice || choice === "取消") return;
|
|
122
|
+
if (choice.startsWith("恢复 ")) resumedState = storedState;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let template = initialTemplate;
|
|
126
|
+
if (!resumedState && !template && !initialBundle && !args.trim() && (listCloudPromptTemplates().length > 0 || listCloudDeploymentTemplates().length > 0)) {
|
|
127
|
+
const source = await ctx.ui.select("Cloud 任务来源", ["新建任务", "使用已有 Prompt Template", "使用 Terraform Deployment Template", "取消"]
|
|
128
|
+
.filter((item) => item !== "使用已有 Prompt Template" || listCloudPromptTemplates().length > 0)
|
|
129
|
+
.filter((item) => item !== "使用 Terraform Deployment Template" || listCloudDeploymentTemplates().length > 0));
|
|
130
|
+
if (!source || source === "取消") return;
|
|
131
|
+
if (source === "使用已有 Prompt Template") { template = await choosePromptTemplate(ctx); if (!template) return; }
|
|
132
|
+
if (source === "使用 Terraform Deployment Template") { initialBundle = await chooseDeploymentTemplate(ctx); if (!initialBundle) return; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let vendor: CloudVendorId;
|
|
136
|
+
let deployCurrentProject: boolean;
|
|
137
|
+
let request: string;
|
|
138
|
+
let templateGuidance: string | undefined;
|
|
139
|
+
if (resumedState) {
|
|
140
|
+
const details = cloudDetails(resumedState)!;
|
|
141
|
+
({ vendor, deployCurrentProject, request } = details);
|
|
142
|
+
} else if (initialBundle) {
|
|
143
|
+
vendor = initialBundle.manifest.vendor;
|
|
144
|
+
deployCurrentProject = true;
|
|
145
|
+
request = `复用 Terraform Deployment Template「${initialBundle.manifest.name}」并完成其验证、计划与部署`;
|
|
146
|
+
templateGuidance = existsSync(initialBundle.summaryPath) ? readFileSync(initialBundle.summaryPath, "utf8") : `sourceDigest=${initialBundle.manifest.sourceDigest}`;
|
|
147
|
+
} else if (template) {
|
|
148
|
+
vendor = template.vendor;
|
|
149
|
+
deployCurrentProject = template.deployCurrentProject;
|
|
150
|
+
request = template.objective || template.name;
|
|
151
|
+
templateGuidance = expandCloudPromptTemplate(template, args);
|
|
152
|
+
} else {
|
|
153
|
+
const label = await ctx.ui.select("选择需要对接的云计算厂商", CLOUD_PROVIDERS.map((provider) => provider.label));
|
|
154
|
+
if (!label) return;
|
|
155
|
+
vendor = CLOUD_PROVIDERS.find((provider) => provider.label === label)!.id;
|
|
156
|
+
const deployment = await ctx.ui.select("是否部署当前项目?", ["是,部署当前项目", "否,执行其他云任务"]);
|
|
157
|
+
if (!deployment) return;
|
|
158
|
+
deployCurrentProject = deployment.startsWith("是");
|
|
159
|
+
const entered = (await ctx.ui.editor("描述需要执行的具体需求", args.trim()))?.trim();
|
|
160
|
+
if (!entered) { notify(ctx, "必须提供明确的云任务需求,HWCode Cloud 未启动。", "warning"); return; }
|
|
161
|
+
request = entered;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const vendorLabel = getCloudProvider(vendor).label;
|
|
165
|
+
const cliCheck = await checkProviderCli(vendor, root);
|
|
166
|
+
if (cliCheck.spawnErrorCode === "ENOENT") { notify(ctx, missingCloudCliMessage(vendor), "error"); return; }
|
|
167
|
+
if (cliCheck.spawnErrorCode === "EACCES") { notify(ctx, inaccessibleCloudCliMessage(vendor), "error"); return; }
|
|
168
|
+
|
|
169
|
+
const vaultPath = defaultCloudVaultPath();
|
|
170
|
+
if (!(await ctx.ui.confirm("安全保存云账户信息?", [
|
|
171
|
+
`当前项目根目录将锁定为:\n${root}`,
|
|
172
|
+
`凭据将使用主密码派生密钥,以 AES-256-GCM 加密保存到:\n${vaultPath}`,
|
|
173
|
+
"文件权限限制为当前用户读写,不会发送给模型或上传至 HWCode 服务。",
|
|
174
|
+
"连接校验和后续命令只会把凭据提交给所选云厂商的官方认证端点或 CLI。",
|
|
175
|
+
"如果忘记主密码,加密凭据无法恢复。",
|
|
176
|
+
].join("\n\n")))) return;
|
|
177
|
+
|
|
178
|
+
const unlocked = await unlockVault(ctx);
|
|
179
|
+
if (!unlocked) return;
|
|
180
|
+
const profiles = listCloudCredentialProfiles(unlocked.payload, vendor);
|
|
181
|
+
let selectedProfile: CloudCredentialProfile | undefined;
|
|
182
|
+
let credentials: CloudCredentials | undefined;
|
|
183
|
+
if (profiles.length > 0) {
|
|
184
|
+
const labels = profiles.map((profile, index) => cloudCredentialProfileLabel(profile, index, profiles.length));
|
|
185
|
+
const choice = await ctx.ui.select(`选择 ${vendorLabel} 凭据`, [...labels, "新建凭据", "取消"]);
|
|
186
|
+
if (!choice || choice === "取消") return;
|
|
187
|
+
const selectedIndex = labels.indexOf(choice);
|
|
188
|
+
if (selectedIndex >= 0) { selectedProfile = profiles[selectedIndex]; credentials = { ...selectedProfile.credentials }; }
|
|
189
|
+
}
|
|
190
|
+
while (true) {
|
|
191
|
+
credentials ??= await collectCredentials(vendor, root, ctx);
|
|
192
|
+
if (!credentials) return;
|
|
193
|
+
notify(ctx, `正在校验 ${vendorLabel} 连接…`);
|
|
194
|
+
const validation = await validateCloudCredentials(vendor, { root, credentials, temporaryStore: runtime.temporaryStore });
|
|
195
|
+
if (validation.code === 0) break;
|
|
196
|
+
if (validation.spawnErrorCode === "ENOENT") { notify(ctx, missingCloudCliMessage(vendor), "error"); return; }
|
|
197
|
+
if (validation.spawnErrorCode === "EACCES") { notify(ctx, inaccessibleCloudCliMessage(vendor), "error"); return; }
|
|
198
|
+
notify(ctx, `连接校验失败:${formatValidationFailure(validation, credentials)}`, "error");
|
|
199
|
+
if (!(await ctx.ui.confirm("重新输入账户信息?", "连接尚未建立。请检查凭据、账号状态、网络及对应云 CLI。"))) return;
|
|
200
|
+
credentials = undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let vaultPayload = saveCloudCredentialProfile(unlocked.payload, vendor, credentials, selectedProfile?.id);
|
|
204
|
+
const existingRunnerId = resumedState ? cloudDetails(resumedState)?.runner?.id : undefined;
|
|
205
|
+
runtime.activeRunner = existingRunnerId ? listRemoteTargetProfiles(vaultPayload).find((profile) => profile.id === existingRunnerId) : undefined;
|
|
206
|
+
let runnerPreference: CloudWorkflowDetails["runnerPreference"] = runtime.activeRunner ? "existing" : resumedState ? cloudDetails(resumedState)?.runnerPreference : undefined;
|
|
207
|
+
if (!runtime.activeRunner) {
|
|
208
|
+
const selection = await chooseRunner(ctx, vaultPayload, vendor, root, credentials.region ?? "");
|
|
209
|
+
if (selection.kind === "cancel") return;
|
|
210
|
+
if (selection.kind === "profile") { runtime.activeRunner = selection.profile; runnerPreference = "existing"; }
|
|
211
|
+
else runnerPreference = selection.kind;
|
|
212
|
+
}
|
|
213
|
+
if (runtime.activeRunner && !listRemoteTargetProfiles(vaultPayload).some((profile) => profile.id === runtime.activeRunner!.id)) {
|
|
214
|
+
vaultPayload = saveRemoteTargetProfile(vaultPayload, runtime.activeRunner);
|
|
215
|
+
}
|
|
216
|
+
writeCloudVault(vaultPayload, unlocked.password, vaultPath);
|
|
217
|
+
runtime.activeVault = { password: unlocked.password, payload: vaultPayload, path: vaultPath };
|
|
218
|
+
runtime.cleanupCredentialDirectories();
|
|
219
|
+
runtime.activeCredentials = credentials;
|
|
220
|
+
const artifactWorkspace = resumedState ? undefined : createCloudRunWorkspace(root);
|
|
221
|
+
const materializedBundle = initialBundle && artifactWorkspace ? materializeCloudDeploymentTemplate(initialBundle, artifactWorkspace.path) : undefined;
|
|
222
|
+
runtime.activeState = resumedState ?? createCloudWorkflowState(
|
|
223
|
+
root, vendor, deployCurrentProject, request,
|
|
224
|
+
(template || initialBundle) && templateGuidance ? {
|
|
225
|
+
source: initialBundle ? cloudDeploymentTemplateSource(initialBundle) : cloudPromptTemplateSource(template!),
|
|
226
|
+
guidance: templateGuidance,
|
|
227
|
+
} : undefined,
|
|
228
|
+
artifactWorkspace?.path,
|
|
229
|
+
);
|
|
230
|
+
if (initialBundle && !resumedState) runtime.activeState = runtime.replaceDetails(runtime.activeState, { ...cloudDetails(runtime.activeState)!, terraformSourcePath: materializedBundle!.terraformPath }, runtime.activeState.phase);
|
|
231
|
+
if (runtime.activeRunner) runtime.activeState = runtime.replaceDetails(runtime.activeState, { ...cloudDetails(runtime.activeState)!, runnerPreference, runner: remoteTargetSummary(runtime.activeRunner) }, runtime.activeState.phase);
|
|
232
|
+
else runtime.activeState = runtime.replaceDetails(runtime.activeState, { ...cloudDetails(runtime.activeState)!, runnerPreference }, runtime.activeState.phase);
|
|
233
|
+
notify(ctx, `${vendorLabel} 连接成功。凭据已加密保存,项目根目录锁定为 ${root}。`);
|
|
234
|
+
runtime.pi.sendUserMessage(activationPrompt(runtime.activeState), { expandPromptTemplates: true });
|
|
235
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { cloudDeploymentTemplateSource, listCloudDeploymentTemplates, saveCloudDeploymentTemplate, updateCloudDeploymentTemplate } from "../../../lib/workflows/cloud/bundles.ts";
|
|
4
|
+
import { redactCredentialValues } from "../../../lib/workflows/cloud/providers.ts";
|
|
5
|
+
import { saveCloudWorkflowTemplate } from "../../../lib/workflows/cloud/template-save.ts";
|
|
6
|
+
import { listCloudPromptTemplates, saveCloudPromptTemplate, updateCloudPromptTemplate } from "../../../lib/workflows/cloud/templates.ts";
|
|
7
|
+
import { activateCloudWorkflow, chooseDeploymentTemplate, choosePromptTemplate } from "./activation.ts";
|
|
8
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
9
|
+
import { cloudDetails, formatTemplateTime, notify } from "./shared.ts";
|
|
10
|
+
|
|
11
|
+
export function registerCloudCommands(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
12
|
+
pi.registerCommand("hwcode-cloud", {
|
|
13
|
+
description: "Start or resume the guarded HWCode cloud workflow",
|
|
14
|
+
handler: (args, ctx) => activateCloudWorkflow(runtime, args, ctx),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
pi.registerCommand("hwcode-cloud-template", {
|
|
18
|
+
description: "Start HWCode Cloud from a locally saved prompt or Terraform deployment template",
|
|
19
|
+
handler: async (args, ctx) => {
|
|
20
|
+
const promptTemplates = listCloudPromptTemplates();
|
|
21
|
+
const deploymentTemplates = listCloudDeploymentTemplates();
|
|
22
|
+
const source = await ctx.ui.select("选择本地 Cloud Template 类型", [
|
|
23
|
+
...(promptTemplates.length > 0 ? ["Prompt Template"] : []),
|
|
24
|
+
...(deploymentTemplates.length > 0 ? ["Terraform Deployment Template"] : []),
|
|
25
|
+
"取消",
|
|
26
|
+
]);
|
|
27
|
+
if (source === "Prompt Template") {
|
|
28
|
+
const template = await choosePromptTemplate(ctx);
|
|
29
|
+
if (template) await activateCloudWorkflow(runtime, args, ctx, template);
|
|
30
|
+
} else if (source === "Terraform Deployment Template") {
|
|
31
|
+
const bundle = await chooseDeploymentTemplate(ctx);
|
|
32
|
+
if (bundle) await activateCloudWorkflow(runtime, args, ctx, undefined, bundle);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.registerCommand("hwcode-cloud-save-template", {
|
|
38
|
+
description: "Save or update the successful Cloud path as a Prompt or Terraform Deployment Template",
|
|
39
|
+
handler: async (args, ctx) => {
|
|
40
|
+
const activeState = runtime.restore(ctx);
|
|
41
|
+
const details = activeState && cloudDetails(activeState);
|
|
42
|
+
if (!activeState || !details || (details.successfulSteps.length === 0 && details.terraformRun?.phase !== "applied")) {
|
|
43
|
+
notify(ctx, "当前 Cloud workflow 还没有成功执行步骤,无法生成可复用模板。", "warning");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (details.terraformRun?.phase === "applied" && details.terraformRun.sourcePath) {
|
|
47
|
+
const existing = details.sourceTemplate?.kind === "terraform"
|
|
48
|
+
? listCloudDeploymentTemplates().find((template) => template.manifest.id === details.sourceTemplate?.id)
|
|
49
|
+
: undefined;
|
|
50
|
+
let action: "update" | "create" = "create";
|
|
51
|
+
if (existing) {
|
|
52
|
+
const choice = await ctx.ui.select(`当前会话关联模板「${existing.manifest.name}」`, ["更新原模板", "创建新模板", "取消"]);
|
|
53
|
+
if (!choice || choice === "取消") return;
|
|
54
|
+
action = choice === "更新原模板" ? "update" : "create";
|
|
55
|
+
}
|
|
56
|
+
const name = (action === "update" ? existing!.manifest.name : args.trim() || await ctx.ui.input("Terraform Deployment Template 名称", `${details.vendor}-${new Date().toISOString().slice(0, 10)}`))?.trim();
|
|
57
|
+
if (!name) return;
|
|
58
|
+
try {
|
|
59
|
+
const template = action === "update"
|
|
60
|
+
? updateCloudDeploymentTemplate(existing!, details.terraformRun.sourcePath, activeState, new Date(), details.artifactDirectory)
|
|
61
|
+
: saveCloudDeploymentTemplate(name, details.terraformRun.sourcePath, activeState, undefined, new Date(), details.artifactDirectory);
|
|
62
|
+
runtime.replaceDetails(activeState, { ...details, sourceTemplate: cloudDeploymentTemplateSource(template) });
|
|
63
|
+
notify(ctx, `${action === "update" ? "已更新" : "已保存"} Terraform Deployment Template「${template.manifest.name}」,包含完整性清单、Terraform、Helm、脱敏 discovery/reports 与验证摘要。`);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
notify(ctx, `Terraform Deployment Template 保存失败:${error instanceof Error ? error.message : String(error)}`, "error");
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = await saveCloudWorkflowTemplate({
|
|
71
|
+
state: activeState,
|
|
72
|
+
requestedName: args,
|
|
73
|
+
redact: runtime.activeCredentials ? (value) => redactCredentialValues(value, runtime.activeCredentials!) : undefined,
|
|
74
|
+
interaction: {
|
|
75
|
+
async chooseSourceAction(source) {
|
|
76
|
+
const updateLabel = `更新原模板「${source.name}」`;
|
|
77
|
+
const choice = await ctx.ui.select(`当前会话关联模板「${details.sourceTemplate?.name ?? source.name}」`, [updateLabel, "创建新模板", "取消"]);
|
|
78
|
+
if (!choice || choice === "取消") return "cancel";
|
|
79
|
+
return choice === updateLabel ? "update" : "create";
|
|
80
|
+
},
|
|
81
|
+
inputName: (defaultName) => ctx.ui.input("模板名称", defaultName),
|
|
82
|
+
editNotes: (initialNotes) => ctx.ui.editor("补充必要前置条件、成功经验及必须避免的高消耗错误路径(可留空;不得包含凭据)", initialNotes),
|
|
83
|
+
sourceMissing(sourceName) { notify(ctx, `本次使用的原模板「${sourceName}」已不存在,将创建新模板。`, "warning"); },
|
|
84
|
+
},
|
|
85
|
+
store: {
|
|
86
|
+
list: () => listCloudPromptTemplates(),
|
|
87
|
+
create: (name, state, notes) => saveCloudPromptTemplate(name, state, undefined, notes),
|
|
88
|
+
update: (template, state, notes) => updateCloudPromptTemplate(template, state, notes),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
if (result.status === "cancelled") return;
|
|
92
|
+
runtime.replaceDetails(activeState, result.details);
|
|
93
|
+
notify(ctx, `${result.action === "updated" ? "已更新" : "已创建"}模板「${result.template.name}」(${formatTemplateTime(result.template.updatedAt)})。使用 /hwcode-cloud-template 可立即复用。`);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
4
|
+
import { canonicalizeWorkspaceRoot } from "../../../lib/workflow-guard.ts";
|
|
5
|
+
import { getWorkingDirectory } from "../../../lib/working-directory.ts";
|
|
6
|
+
import { containsCloudCommand, getCloudProvider } from "../../../lib/workflows/cloud/providers.ts";
|
|
7
|
+
import { updateWorkflowState } from "../../../lib/workflows/state.ts";
|
|
8
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
9
|
+
import {
|
|
10
|
+
appendWorkflowState, cloudArtifactDirectory, cloudDetails, notify,
|
|
11
|
+
restoreLegacyCloudWorkflow,
|
|
12
|
+
} from "./shared.ts";
|
|
13
|
+
|
|
14
|
+
export function registerCloudEvents(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
15
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
16
|
+
runtime.restore(ctx);
|
|
17
|
+
if (!runtime.activeState) {
|
|
18
|
+
runtime.activeState = restoreLegacyCloudWorkflow(ctx);
|
|
19
|
+
if (runtime.activeState) appendWorkflowState(pi, runtime.activeState);
|
|
20
|
+
}
|
|
21
|
+
runtime.clearSecrets();
|
|
22
|
+
if (!runtime.activeState) return;
|
|
23
|
+
const root = canonicalizeWorkspaceRoot(getWorkingDirectory(ctx.sessionManager));
|
|
24
|
+
if (root !== runtime.activeState.root) {
|
|
25
|
+
appendWorkflowState(pi, updateWorkflowState(runtime.activeState, { status: "cancelled", phase: "root-changed", reason: "working directory changed" }));
|
|
26
|
+
runtime.activeState = undefined;
|
|
27
|
+
notify(ctx, "Stored HWCode Cloud workflow disabled because the current directory changed.", "warning");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const details = cloudDetails(runtime.activeState)!;
|
|
31
|
+
if (!details.terminalFailure) notify(ctx, `HWCode Cloud session restored for ${getCloudProvider(details.vendor).label}. Credentials are locked; run /hwcode-cloud to unlock before continuing.`, "warning");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
35
|
+
const activeState = runtime.restore(ctx);
|
|
36
|
+
const details = activeState && cloudDetails(activeState);
|
|
37
|
+
if (!activeState || !details) return undefined;
|
|
38
|
+
const failureRule = details.terminalFailure
|
|
39
|
+
? "Three distinct approaches already failed. Only summarize causes, progress, and changes; do not continue execution."
|
|
40
|
+
: `Distinct failed approaches: ${details.failedApproaches.length}/${CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches}. After the third distinct approach fails, stop and summarize.`;
|
|
41
|
+
return {
|
|
42
|
+
systemPrompt: `${event.systemPrompt}\n\nHWCODE CLOUD ACTIVE\nProvider: ${details.vendor}\nObjective: ${details.request}\nArtifact workspace: ${cloudArtifactDirectory(activeState)}\nCredentials must never be requested in chat, printed, placed in tool arguments, or read from the vault. Write all generated Cloud artifacts only under the artifact workspace: discovery/ for CLI skeletons and snapshots, terraform/ for IaC, charts/ for Helm sources/packages, reports/ for plans and summaries. Project source remains under ${activeState.root}; when a provider CLI needs a project source file, pass its absolute project-root path. Use hwcode_cloud_exec for provider CLI operations. ${details.runner ? "Use hwcode_runner_prepare and hwcode_terraform_* for Terraform source synchronization, validation, planning, approval, and apply on the selected Runner." : details.runnerPreference === "automatic" ? "The user requested an automatic temporary Runner. Provision it through approved provider CLI changes with workload identity and cloud-init, then register the discovered endpoint with hwcode_runner_connect." : "No Terraform Runner is selected. Do not provision one unless the user explicitly changes this choice."} Resource creates and changes require approval unless the user opted out; deletion always requires approval. After success, offer /hwcode-cloud-save-template to preserve the validated path locally. ${failureRule}`,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
47
|
+
const activeState = runtime.restore(ctx);
|
|
48
|
+
if (!activeState || event.toolName !== "bash") return undefined;
|
|
49
|
+
const input = event.input as Record<string, unknown>;
|
|
50
|
+
if (typeof input.command !== "string" || !containsCloudCommand(input.command)) return undefined;
|
|
51
|
+
return { block: true, reason: "Direct cloud and infrastructure CLI use is blocked during HWCode Cloud. Use hwcode_cloud_exec so credentials remain isolated and approvals are enforced." };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.on("session_shutdown", async () => {
|
|
55
|
+
runtime.activeState = undefined;
|
|
56
|
+
runtime.clearSecrets();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerCloudCommands } from "./commands.ts";
|
|
4
|
+
import { registerCloudEvents } from "./events.ts";
|
|
5
|
+
import { registerCloudProviderTools } from "./provider-tools.ts";
|
|
6
|
+
import { registerCloudRunnerTools } from "./runner-tools.ts";
|
|
7
|
+
import { CloudExtensionRuntime } from "./runtime.ts";
|
|
8
|
+
import { registerCloudTerraformTools } from "./terraform-tools.ts";
|
|
9
|
+
|
|
10
|
+
export function registerCloudWorkflow(pi: ExtensionAPI): void {
|
|
11
|
+
const runtime = new CloudExtensionRuntime(pi);
|
|
12
|
+
registerCloudCommands(pi, runtime);
|
|
13
|
+
registerCloudProviderTools(pi, runtime);
|
|
14
|
+
registerCloudRunnerTools(pi, runtime);
|
|
15
|
+
registerCloudTerraformTools(pi, runtime);
|
|
16
|
+
registerCloudEvents(pi, runtime);
|
|
17
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { CLOUD_RUNTIME_DEFAULTS } from "../../../lib/runtime/defaults.ts";
|
|
7
|
+
import { evaluateCommandArgumentsAccess } from "../../../lib/workspace/access-policy.ts";
|
|
8
|
+
import { prepareCloudExecution } from "../../../lib/workflows/cloud/adapters.ts";
|
|
9
|
+
import { cloudExecutionBlockReason, recordStrategyFailure } from "../../../lib/workflows/cloud/execution.ts";
|
|
10
|
+
import { runProcess, truncateOutput, type ProcessResult } from "../../../lib/workflows/cloud/process.ts";
|
|
11
|
+
import { CLOUD_EXECUTABLES, classifyCloudOperation, getCloudProvider, redactCredentialValues, type CloudOperation } from "../../../lib/workflows/cloud/providers.ts";
|
|
12
|
+
import { WORKFLOW_EXTERNAL_AUDIT_TYPE } from "../../../lib/workflows/state.ts";
|
|
13
|
+
import type { CloudExtensionRuntime } from "./runtime.ts";
|
|
14
|
+
import {
|
|
15
|
+
CLOUD_AUDIT_TYPE, ORCHESTRATION_COMMANDS, SENSITIVE_ARGUMENT, approveOperation,
|
|
16
|
+
cloudArtifactDirectory, cloudDetails, safeStepText, terraformError, terraformOk,
|
|
17
|
+
} from "./shared.ts";
|
|
18
|
+
|
|
19
|
+
export function registerCloudProviderTools(pi: ExtensionAPI, runtime: CloudExtensionRuntime): void {
|
|
20
|
+
pi.registerTool(defineTool({
|
|
21
|
+
name: "hwcode_cloud_record_resource",
|
|
22
|
+
label: "HWCode Cloud Resource Inventory",
|
|
23
|
+
description: "Record non-secret cloud resource identifiers after discovery, creation, or confirmed deletion so cleanup and completion remain auditable.",
|
|
24
|
+
promptSnippet: "Keep the Cloud resource inventory current after resource changes.",
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
id: Type.String(), type: Type.String(), region: Type.String(),
|
|
27
|
+
ownership: Type.Union([Type.Literal("existing"), Type.Literal("workflow-created")]),
|
|
28
|
+
status: Type.Union([Type.Literal("active"), Type.Literal("deleted")]),
|
|
29
|
+
}),
|
|
30
|
+
executionMode: "sequential",
|
|
31
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
32
|
+
const activeState = runtime.restore(ctx);
|
|
33
|
+
const blocked = cloudExecutionBlockReason(activeState, { allowAfterTerminalFailure: true });
|
|
34
|
+
if (!activeState || blocked) return terraformError(blocked ?? "HWCode Cloud is not active.");
|
|
35
|
+
const details = cloudDetails(activeState)!;
|
|
36
|
+
const clean = (value: string, limit: number) => value.replace(/[\r\n\0]/gu, " ").trim().slice(0, limit);
|
|
37
|
+
const id = clean(params.id, 256);
|
|
38
|
+
const type = clean(params.type, 128);
|
|
39
|
+
const region = clean(params.region, 128);
|
|
40
|
+
if (!id || !type || !region) return terraformError("Resource id, type, and region are required.");
|
|
41
|
+
const resources = [...(details.resources ?? [])];
|
|
42
|
+
const index = resources.findIndex((resource) => resource.id === id && resource.type === type && resource.region === region);
|
|
43
|
+
const record = { id, type, region, ownership: params.ownership, status: params.status, updatedAt: new Date().toISOString() } as const;
|
|
44
|
+
if (index >= 0) resources[index] = record;
|
|
45
|
+
else resources.push(record);
|
|
46
|
+
runtime.replaceDetails(activeState, { ...details, resources: resources.slice(-500) });
|
|
47
|
+
return terraformOk(`Resource inventory updated: ${type} ${id} (${params.status}).`, { resource: record });
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
pi.registerTool(defineTool({
|
|
52
|
+
name: "hwcode_cloud_exec",
|
|
53
|
+
label: "HWCode Cloud",
|
|
54
|
+
description: "Run one cloud or infrastructure CLI command with isolated credentials and unified workspace/resource approvals.",
|
|
55
|
+
promptSnippet: "Execute credential-isolated cloud CLI commands with mandatory resource-change approvals.",
|
|
56
|
+
parameters: Type.Object({
|
|
57
|
+
command: Type.String({ description: "Executable name only" }),
|
|
58
|
+
args: Type.Array(Type.String(), { description: "Argument vector; never include credentials or shell syntax" }),
|
|
59
|
+
operation: Type.Union([Type.Literal("read"), Type.Literal("change"), Type.Literal("delete")]),
|
|
60
|
+
intent: Type.String({ description: "Concise description of what this command inspects or changes" }),
|
|
61
|
+
approach: Type.String({ description: "Stable name for this technical approach" }),
|
|
62
|
+
}),
|
|
63
|
+
executionMode: "sequential",
|
|
64
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
65
|
+
let activeState = runtime.restore(ctx);
|
|
66
|
+
let details = activeState && cloudDetails(activeState);
|
|
67
|
+
const blocked = cloudExecutionBlockReason(activeState, { allowAfterTerminalFailure: true });
|
|
68
|
+
if (!activeState || !details || blocked) return { content: [{ type: "text" as const, text: blocked ?? "HWCode Cloud is not active." }], isError: true, details: { terminalFailure: details?.terminalFailure } };
|
|
69
|
+
if (!runtime.activeCredentials) return { content: [{ type: "text" as const, text: "Cloud credentials are locked after restoration. Run /hwcode-cloud to unlock and validate them." }], isError: true, details: {} };
|
|
70
|
+
const credentials = runtime.activeCredentials;
|
|
71
|
+
if (basename(params.command) !== params.command || !CLOUD_EXECUTABLES.has(params.command)) return { content: [{ type: "text" as const, text: `Unsupported executable: ${params.command}` }], isError: true, details: {} };
|
|
72
|
+
if (["terraform", "tofu", "pulumi"].includes(params.command)) return { content: [{ type: "text" as const, text: "Terraform/OpenTofu/Pulumi must use the structured hwcode_terraform_* tools so source policy, remote workspace isolation, plan digests, and approvals remain enforceable." }], isError: true, details: {} };
|
|
73
|
+
const providerCli = getCloudProvider(details.vendor).cli;
|
|
74
|
+
if (params.command !== providerCli && !ORCHESTRATION_COMMANDS.has(params.command)) return { content: [{ type: "text" as const, text: `${params.command} does not match the active ${details.vendor} provider.` }], isError: true, details: {} };
|
|
75
|
+
if (params.args.some((argument) => SENSITIVE_ARGUMENT.test(argument))) return { content: [{ type: "text" as const, text: "Credential-like command arguments are forbidden. HWCode injects credentials outside model context." }], isError: true, details: {} };
|
|
76
|
+
|
|
77
|
+
const operation = classifyCloudOperation(params.command, params.args, params.operation as CloudOperation);
|
|
78
|
+
if (details.terminalFailure && operation === "change") return { content: [{ type: "text" as const, text: "Failure budget reached. New resource changes are blocked; only read-only inspection and confirmed cleanup deletion remain available." }], isError: true, details: { terminalFailure: true } };
|
|
79
|
+
const external = evaluateCommandArgumentsAccess(params.command, params.args, activeState.root);
|
|
80
|
+
if (external.length > 0) {
|
|
81
|
+
const summary = external.map((entry) => `• ${entry.raw} → ${entry.resolved}`).join("\n");
|
|
82
|
+
if (!ctx.hasUI || !(await ctx.ui.confirm("允许本次云工具访问项目目录之外的路径?", `项目根目录:${activeState.root}\n\n${summary}\n\n该授权仅适用于本次命令。`))) {
|
|
83
|
+
return { content: [{ type: "text" as const, text: `User denied external path access:\n${summary}` }], isError: true, details: { denied: true } };
|
|
84
|
+
}
|
|
85
|
+
pi.appendEntry(WORKFLOW_EXTERNAL_AUDIT_TYPE, { mode: "cloud", root: activeState.root, toolName: "hwcode_cloud_exec", external, approvedAt: new Date().toISOString() });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const approval = await approveOperation(operation, params, activeState, ctx);
|
|
89
|
+
if (approval === "deny") return { content: [{ type: "text" as const, text: `User denied ${operation} operation: ${params.intent}` }], isError: true, details: { denied: true, operation } };
|
|
90
|
+
if (approval === "allow-session") {
|
|
91
|
+
activeState = runtime.replaceDetails(activeState, { ...details, allowNonDeleteChanges: true });
|
|
92
|
+
details = cloudDetails(activeState)!;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const prepared = await prepareCloudExecution(details.vendor, params.command, params.args, { root: activeState.root, credentials, temporaryStore: runtime.temporaryStore, signal });
|
|
96
|
+
let result: ProcessResult;
|
|
97
|
+
try {
|
|
98
|
+
result = prepared.error
|
|
99
|
+
? { stdout: "", stderr: prepared.error, code: 1, killed: false }
|
|
100
|
+
: await runProcess(params.command, prepared.args, { cwd: cloudArtifactDirectory(activeState), env: prepared.env, signal });
|
|
101
|
+
} finally { prepared.cleanup?.(); }
|
|
102
|
+
|
|
103
|
+
const stdout = truncateOutput(redactCredentialValues(result.stdout, credentials));
|
|
104
|
+
const stderr = truncateOutput(redactCredentialValues(result.stderr, credentials));
|
|
105
|
+
pi.appendEntry(CLOUD_AUDIT_TYPE, { vendor: details.vendor, command: params.command, operation, intent: params.intent, approach: params.approach, exitCode: result.code, executedAt: new Date().toISOString() });
|
|
106
|
+
if (result.code !== 0) {
|
|
107
|
+
const failed = recordStrategyFailure(details, params.approach, params.command, stderr.trim() || stdout.trim() || `exit code ${result.code}`, CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches);
|
|
108
|
+
runtime.replaceDetails(activeState, failed, failed.terminalFailure ? "failed" : "executing");
|
|
109
|
+
const suffix = failed.terminalFailure
|
|
110
|
+
? "\n\nTERMINAL FAILURE: Three materially different approaches failed. Explain causes and summarize progress and changes. Do not try another cloud operation."
|
|
111
|
+
: `\n\nDistinct failed approaches: ${failed.failedApproaches.length}/${CLOUD_RUNTIME_DEFAULTS.workflow.maxFailedApproaches}.`;
|
|
112
|
+
return { content: [{ type: "text" as const, text: `Cloud command failed with exit code ${result.code}.\n${stderr || stdout}${suffix}` }], isError: true, details: { operation, exitCode: result.code, terminalFailure: failed.terminalFailure } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const successfulSteps = [...details.successfulSteps, {
|
|
116
|
+
command: safeStepText(params.command, credentials),
|
|
117
|
+
args: params.args.map((argument) => safeStepText(argument, credentials)),
|
|
118
|
+
operation,
|
|
119
|
+
intent: safeStepText(params.intent, credentials),
|
|
120
|
+
approach: safeStepText(params.approach, credentials),
|
|
121
|
+
completedAt: new Date().toISOString(),
|
|
122
|
+
}].slice(-CLOUD_RUNTIME_DEFAULTS.workflow.maxSuccessfulSteps);
|
|
123
|
+
runtime.replaceDetails(activeState, { ...details, successfulSteps }, "executing");
|
|
124
|
+
return { content: [{ type: "text" as const, text: stdout.trim() || stderr.trim() || "Cloud command completed successfully." }], details: { operation, exitCode: 0 } };
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
}
|