agentlas 0.5.2 → 0.5.5
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/README.md +48 -6
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +40 -56
- package/engine/agentlas-capabilities.cjs +3 -0
- package/engine/agentlas-cloud-runtime.cjs +65 -11
- package/engine/agentlas-composer.cjs +109 -44
- package/engine/agentlas-doctor.cjs +40 -12
- package/engine/agentlas-i18n.cjs +120 -12
- package/engine/agentlas-input.cjs +116 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +315 -45
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +99 -45
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +348 -23
- package/engine/agentlas.cjs +2742 -338
- package/engine/semver.cjs +64 -0
- package/package.json +1 -1
- package/test/bootstrap-race.cjs +47 -0
- package/test/capture-runtime-guard.cjs +122 -0
- package/test/cloud-asset-restore.cjs +423 -0
- package/test/cloud-cas-client.cjs +333 -0
- package/test/cloud-owner-restore.cjs +183 -0
- package/test/cloud-runtime-paths.cjs +40 -0
- package/test/cloud-save-publish.cjs +453 -0
- package/test/credential-env-regression.cjs +52 -0
- package/test/login-loopback-security.cjs +115 -0
- package/test/mcp-config-isolation.cjs +36 -0
- package/test/permission-mapping.cjs +180 -0
- package/test/run-api-regression.cjs +322 -0
- package/test/runtime-env-protection.cjs +45 -0
- package/test/semver-precedence.cjs +39 -0
- package/test/smoke.sh +19 -0
- package/test/sqlite-driver-probe.cjs +22 -0
- package/test/terminal-ui-regression.cjs +454 -0
- package/test/timeout-regression.cjs +218 -0
- package/test/tool-workspace-boundary.cjs +165 -0
- package/test/update-safety.cjs +376 -0
package/engine/agentlas.cjs
CHANGED
|
@@ -28,6 +28,7 @@ const os = require("node:os");
|
|
|
28
28
|
const fs = require("node:fs");
|
|
29
29
|
const { spawn } = require("node:child_process");
|
|
30
30
|
const crypto = require("node:crypto");
|
|
31
|
+
const { compareSemVer, normalizeSemVer, parseSemVer } = require("./semver.cjs");
|
|
31
32
|
|
|
32
33
|
// ── 앱과 동일한 userData 경로 (electron app.getPath('userData')와 일치) ──
|
|
33
34
|
function userDataDir() {
|
|
@@ -138,6 +139,8 @@ function loadMultimodalCatalog() {
|
|
|
138
139
|
} catch {
|
|
139
140
|
const providers = [
|
|
140
141
|
{ id: "codex-cli-image", modality: "image", label: "Codex CLI image", labelKo: "Codex CLI 이미지", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
|
|
142
|
+
{ id: "grok-cli-image", modality: "image", label: "Grok CLI image (Imagine)", labelKo: "Grok CLI 이미지 (Imagine)", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
|
|
143
|
+
{ id: "grok-cli-video", modality: "video", label: "Grok CLI video (Imagine)", labelKo: "Grok CLI 영상 (Imagine)", envKeys: [], billing: "subscription", defaultModel: "runtime-default" },
|
|
141
144
|
{ id: "openai-image", modality: "image", label: "OpenAI Images API", labelKo: "OpenAI 이미지 API", envKeys: ["OPENAI_API_KEY"], billing: "paid-api", defaultModel: "gpt-image-2" },
|
|
142
145
|
{ id: "google-image", modality: "image", label: "Google Gemini Image", labelKo: "Google Gemini 이미지", envKeys: ["GOOGLE_API_KEY"], billing: "paid-api", defaultModel: "gemini-image" },
|
|
143
146
|
{ id: "runway-video", modality: "video", label: "Runway API", labelKo: "Runway API", envKeys: ["RUNWAY_API_KEY"], billing: "paid-api", defaultModel: "gen4.5" },
|
|
@@ -544,8 +547,13 @@ function agentFolder(agent) {
|
|
|
544
547
|
const routes = routesMap();
|
|
545
548
|
const r = routes[agent.id];
|
|
546
549
|
if (r && r.path) return r.path; // 로컬 임포트는 원본 폴더
|
|
550
|
+
const cloudRoot = path.join(userDataDir(), "cloud-agent-installs", cloudSlug(agent.slug));
|
|
551
|
+
if (exists(path.join(cloudRoot, CLOUD_RESTORE_MARKER_PATH))) return cloudRoot;
|
|
547
552
|
return path.join(userDataDir(), "agents", agent.slug);
|
|
548
553
|
}
|
|
554
|
+
function agentSystemPromptCli(agent) {
|
|
555
|
+
return agent && agent.system_prompt ? agent.system_prompt : `You are ${agent?.name || "an Agentlas agent"}.`;
|
|
556
|
+
}
|
|
549
557
|
|
|
550
558
|
// ── 로컬 폴더 임포트 (앱의 electron/agents/import-local.ts 와 동일 규칙) ──
|
|
551
559
|
// 터미널에서 "폴더 드래그" = `agentlas import <path>`. 앱과 같은 DB/라우트를 공유한다.
|
|
@@ -767,10 +775,15 @@ function cmdImport(db, absPath) {
|
|
|
767
775
|
const CLOUD_MAX_TOTAL_BYTES = 3 * 1024 * 1024;
|
|
768
776
|
const CLOUD_MAX_FILE_BYTES = 512 * 1024;
|
|
769
777
|
const CLOUD_MAX_FILES = 400;
|
|
770
|
-
const
|
|
778
|
+
const CLOUD_PACKAGE_HASH_V1 = "path-sha256-v1";
|
|
779
|
+
const CLOUD_PACKAGE_HASH_V2 = "path-sha256-executable-v2";
|
|
780
|
+
const CLOUD_RESTORE_MARKER_PATH = ".agentlas-cloud-package.json";
|
|
781
|
+
const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
|
|
782
|
+
const CLOUD_ASSET_SCOPES = new Set(["owner-private", "hub-public"]);
|
|
783
|
+
const CLOUD_TEXT_EXTS = new Set([".cfg", ".cjs", ".conf", ".config", ".css", ".csv", ".env", ".html", ".ini", ".js", ".json", ".jsonl", ".md", ".mjs", ".properties", ".ps1", ".psd1", ".psm1", ".py", ".sh", ".toml", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml"]);
|
|
771
784
|
const CLOUD_AGENT_FILES = new Set(["AGENT.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "README.md", "agent.md", "manifest.md", "system-prompt.md"]);
|
|
772
785
|
const CLOUD_SKIP_DIRS = new Set([".git", ".next", ".studio-runtime", ".turbo", "build", "coverage", "dist", "node_modules", "out", "release"]);
|
|
773
|
-
const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
|
|
786
|
+
const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
|
|
774
787
|
const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
|
|
775
788
|
const CLOUD_ROUTING_CARD_CAPABILITY_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
|
|
776
789
|
const CLOUD_ROUTING_CARD_STATUSES = new Set(["draft", "searchable", "candidate", "routing_ready", "trusted"]);
|
|
@@ -778,11 +791,149 @@ const CLOUD_SECRET_RE = [
|
|
|
778
791
|
["private-key", /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i, "private key material"],
|
|
779
792
|
["openai-key", /\bsk-[A-Za-z0-9_-]{20,}\b/, "OpenAI-style API key"],
|
|
780
793
|
["github-token", /\bgh[pousr]_[A-Za-z0-9_]{30,}\b/, "GitHub token"],
|
|
794
|
+
["gitlab-token", /\bglpat-[A-Za-z0-9_-]{20,}\b/, "GitLab token"],
|
|
795
|
+
["google-api-key", /\bAIza[0-9A-Za-z_-]{35}\b/, "Google API key"],
|
|
796
|
+
["npm-token", /\bnpm_[A-Za-z0-9]{30,}\b/, "npm access token"],
|
|
797
|
+
["stripe-secret", /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/, "Stripe secret key"],
|
|
781
798
|
["slack-token", /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/, "Slack token"],
|
|
782
799
|
["aws-key", /\bAKIA[0-9A-Z]{16}\b/, "AWS access key"],
|
|
783
800
|
["generic-secret", /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*['"][^'"]{8,}['"]/i, "hard-coded credential"],
|
|
784
801
|
];
|
|
785
802
|
|
|
803
|
+
const HUB_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
|
804
|
+
const HUB_TIMEOUT_DEFAULTS = Object.freeze({ connectMs: 15_000, idleMs: 30_000, totalMs: 180_000 });
|
|
805
|
+
|
|
806
|
+
function finiteTimeoutMs(value, fallback, min, max) {
|
|
807
|
+
const parsed = Number(value);
|
|
808
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
809
|
+
return Math.min(max, Math.max(min, Math.trunc(parsed)));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function hubTimeoutConfig(env = process.env) {
|
|
813
|
+
const totalMs = finiteTimeoutMs(env.AGENTLAS_HUB_TOTAL_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.totalMs, 5_000, 900_000);
|
|
814
|
+
return {
|
|
815
|
+
connectMs: Math.min(totalMs, finiteTimeoutMs(env.AGENTLAS_HUB_CONNECT_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.connectMs, 1_000, 120_000)),
|
|
816
|
+
idleMs: Math.min(totalMs, finiteTimeoutMs(env.AGENTLAS_HUB_IDLE_TIMEOUT_MS, HUB_TIMEOUT_DEFAULTS.idleMs, 1_000, 300_000)),
|
|
817
|
+
totalMs,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function directHubTimeoutConfig(value = {}) {
|
|
822
|
+
const totalMs = finiteTimeoutMs(value.totalMs, HUB_TIMEOUT_DEFAULTS.totalMs, 10, 900_000);
|
|
823
|
+
return {
|
|
824
|
+
connectMs: Math.min(totalMs, finiteTimeoutMs(value.connectMs, HUB_TIMEOUT_DEFAULTS.connectMs, 10, 120_000)),
|
|
825
|
+
idleMs: Math.min(totalMs, finiteTimeoutMs(value.idleMs, HUB_TIMEOUT_DEFAULTS.idleMs, 10, 300_000)),
|
|
826
|
+
totalMs,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function hubTimeoutError(kind, ms) {
|
|
831
|
+
const message = kind === "connect"
|
|
832
|
+
? `Hub 연결 제한 시간(${ms}ms)을 초과했습니다.`
|
|
833
|
+
: kind === "idle"
|
|
834
|
+
? `Hub 응답이 ${ms}ms 동안 멈췄습니다.`
|
|
835
|
+
: `Hub 요청 전체 제한 시간(${ms}ms)을 초과했습니다.`;
|
|
836
|
+
const error = new Error(message);
|
|
837
|
+
error.code = `AGENTLAS_HUB_${kind.toUpperCase()}_TIMEOUT`;
|
|
838
|
+
return error;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Hub/Cloud fetch + body reader. Headers 전 connect, chunk 사이 idle, 전 구간 total timeout. */
|
|
842
|
+
async function fetchHubCli(url, init = {}, options = {}) {
|
|
843
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
844
|
+
if (typeof fetchImpl !== "function") throw new Error("이 런타임에 fetch가 없습니다.");
|
|
845
|
+
const timeout = options.timeoutConfig ? directHubTimeoutConfig(options.timeoutConfig) : hubTimeoutConfig(options.env || process.env);
|
|
846
|
+
const controller = new AbortController();
|
|
847
|
+
const upstreamSignal = init.signal;
|
|
848
|
+
let connectTimer = null;
|
|
849
|
+
let idleTimer = null;
|
|
850
|
+
let totalTimer = null;
|
|
851
|
+
let reader = null;
|
|
852
|
+
let terminalError = null;
|
|
853
|
+
let rejectTerminal;
|
|
854
|
+
const terminal = new Promise((_, reject) => { rejectTerminal = reject; });
|
|
855
|
+
const stop = (error) => {
|
|
856
|
+
if (terminalError) return;
|
|
857
|
+
terminalError = error;
|
|
858
|
+
try { controller.abort(error); } catch { controller.abort(); }
|
|
859
|
+
rejectTerminal(error);
|
|
860
|
+
};
|
|
861
|
+
const onUpstreamAbort = () => {
|
|
862
|
+
const reason = upstreamSignal && upstreamSignal.reason;
|
|
863
|
+
const error = reason instanceof Error ? reason : new Error("Hub 요청이 취소되었습니다.");
|
|
864
|
+
if (!error.code) error.code = "ABORT_ERR";
|
|
865
|
+
stop(error);
|
|
866
|
+
};
|
|
867
|
+
const armIdle = () => {
|
|
868
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
869
|
+
idleTimer = setTimeout(() => stop(hubTimeoutError("idle", timeout.idleMs)), timeout.idleMs);
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
if (upstreamSignal) {
|
|
873
|
+
if (upstreamSignal.aborted) onUpstreamAbort();
|
|
874
|
+
else upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
875
|
+
}
|
|
876
|
+
connectTimer = setTimeout(() => stop(hubTimeoutError("connect", timeout.connectMs)), timeout.connectMs);
|
|
877
|
+
totalTimer = setTimeout(() => stop(hubTimeoutError("total", timeout.totalMs)), timeout.totalMs);
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const response = await Promise.race([
|
|
881
|
+
Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal })),
|
|
882
|
+
terminal,
|
|
883
|
+
]);
|
|
884
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
885
|
+
connectTimer = null;
|
|
886
|
+
const chunks = [];
|
|
887
|
+
let bytes = 0;
|
|
888
|
+
armIdle();
|
|
889
|
+
if (response.body && typeof response.body.getReader === "function") {
|
|
890
|
+
reader = response.body.getReader();
|
|
891
|
+
while (true) {
|
|
892
|
+
const part = await Promise.race([reader.read(), terminal]);
|
|
893
|
+
if (part.done) break;
|
|
894
|
+
armIdle();
|
|
895
|
+
const chunk = Buffer.from(part.value || []);
|
|
896
|
+
bytes += chunk.length;
|
|
897
|
+
if (bytes > HUB_RESPONSE_MAX_BYTES) {
|
|
898
|
+
const error = new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
|
|
899
|
+
error.code = "AGENTLAS_HUB_RESPONSE_TOO_LARGE";
|
|
900
|
+
stop(error);
|
|
901
|
+
throw error;
|
|
902
|
+
}
|
|
903
|
+
chunks.push(chunk);
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
const raw = Buffer.from(await Promise.race([response.arrayBuffer(), terminal]));
|
|
907
|
+
bytes = raw.length;
|
|
908
|
+
if (bytes > HUB_RESPONSE_MAX_BYTES) throw new Error(`Hub 응답이 허용 크기(${HUB_RESPONSE_MAX_BYTES} bytes)를 초과했습니다.`);
|
|
909
|
+
chunks.push(raw);
|
|
910
|
+
}
|
|
911
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
912
|
+
idleTimer = null;
|
|
913
|
+
const text = Buffer.concat(chunks, bytes).toString("utf8");
|
|
914
|
+
return { ok: response.ok, status: response.status, headers: response.headers, text };
|
|
915
|
+
} catch (error) {
|
|
916
|
+
if (terminalError) throw terminalError;
|
|
917
|
+
throw error;
|
|
918
|
+
} finally {
|
|
919
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
920
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
921
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
922
|
+
if (upstreamSignal) upstreamSignal.removeEventListener?.("abort", onUpstreamAbort);
|
|
923
|
+
if (reader && terminalError) {
|
|
924
|
+
try { await reader.cancel(terminalError); } catch { /* ignore */ }
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function parseHubJsonCli(response, label) {
|
|
930
|
+
try {
|
|
931
|
+
return JSON.parse(response.text || "null");
|
|
932
|
+
} catch {
|
|
933
|
+
throw new Error(`${label} 응답 JSON 형식이 올바르지 않습니다.`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
786
937
|
function parseCloudFlags(args) {
|
|
787
938
|
const flags = { _: [] };
|
|
788
939
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -803,6 +954,35 @@ function parseCloudFlags(args) {
|
|
|
803
954
|
return flags;
|
|
804
955
|
}
|
|
805
956
|
|
|
957
|
+
function cloudVisibilityFlag(value) {
|
|
958
|
+
if (value == null) return null;
|
|
959
|
+
if (value === "private-link" || value === "marketplace") return value;
|
|
960
|
+
throw new Error("--visibility must be private-link or marketplace");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function cloudVisibilityForAction(sub, flags) {
|
|
964
|
+
const explicit = cloudVisibilityFlag(flags.visibility);
|
|
965
|
+
if (sub === "save") {
|
|
966
|
+
if (explicit === "marketplace") {
|
|
967
|
+
throw new Error("`agentlas cloud save` is owner-private. Use `agentlas cloud publish` for the public Hub.");
|
|
968
|
+
}
|
|
969
|
+
return "private-link";
|
|
970
|
+
}
|
|
971
|
+
if (sub === "publish") {
|
|
972
|
+
if (explicit === "private-link") {
|
|
973
|
+
throw new Error("`agentlas cloud publish` is public Hub publication. Use `agentlas cloud save` for owner-private Agent Cloud storage.");
|
|
974
|
+
}
|
|
975
|
+
return "marketplace";
|
|
976
|
+
}
|
|
977
|
+
if (explicit) return explicit;
|
|
978
|
+
return "private-link";
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function cloudActionForTopLevelUpload(args) {
|
|
982
|
+
const flags = parseCloudFlags(args);
|
|
983
|
+
return cloudVisibilityFlag(flags.visibility) === "marketplace" ? "publish" : "save";
|
|
984
|
+
}
|
|
985
|
+
|
|
806
986
|
async function cmdCloud(db, args, runtimeOverride) {
|
|
807
987
|
const sub = args[0] || "help";
|
|
808
988
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
@@ -815,28 +995,58 @@ async function cmdCloud(db, args, runtimeOverride) {
|
|
|
815
995
|
" runtime read-agent-file <path> <file>",
|
|
816
996
|
" lazy read with allow/deny gates",
|
|
817
997
|
" field-test [--json] run local Cloud contract field test",
|
|
818
|
-
"
|
|
998
|
+
" save <path> [--dry-run] [--slug name]",
|
|
999
|
+
" save owner-private in Agent Cloud (default upload)",
|
|
819
1000
|
" publish <path> [--dry-run] [--llm-review] [--slug name]",
|
|
820
|
-
"
|
|
821
|
-
"
|
|
822
|
-
"
|
|
1001
|
+
" explicitly publish to the public Agentlas Hub",
|
|
1002
|
+
" package <path> [--json] [--visibility private-link|marketplace]",
|
|
1003
|
+
" package only; defaults to private-save checks",
|
|
1004
|
+
" list [--json] list packages in your private Agent Cloud",
|
|
1005
|
+
" restore <slug> [--json] restore an owned Cloud package on this machine",
|
|
1006
|
+
" install <slug> compatibility alias: install from the public Hub",
|
|
1007
|
+
" delete <slug> [--scope owner-private|hub-public] [--json]",
|
|
1008
|
+
" conditionally delete one exact observed Cloud revision",
|
|
823
1009
|
" search \"<what you need>\" [--limit 10]",
|
|
824
|
-
" search the
|
|
1010
|
+
" search the public Hub (no sign-in needed)",
|
|
825
1011
|
"",
|
|
826
|
-
"
|
|
827
|
-
"--llm-review
|
|
1012
|
+
"Private save rule: no public review or routing card; local secret/path/hash checks remain.",
|
|
1013
|
+
"--llm-review applies only to public Hub publishing and uses this machine's runtime.",
|
|
828
1014
|
].join("\n"));
|
|
829
1015
|
return;
|
|
830
1016
|
}
|
|
831
1017
|
if (sub === "search") {
|
|
832
1018
|
return parity().cloudSearch(db, args.slice(1));
|
|
833
1019
|
}
|
|
1020
|
+
if (sub === "list") {
|
|
1021
|
+
const flags = parseCloudFlags(args.slice(1));
|
|
1022
|
+
const result = await listOwnedCloudAgentsCli(Number(flags.limit || 100));
|
|
1023
|
+
if (flags.json) return out(JSON.stringify(result, null, 2));
|
|
1024
|
+
const agents = Array.isArray(result.results) ? result.results : [];
|
|
1025
|
+
if (!agents.length) return out("Private Agent Cloud에 저장된 에이전트가 없습니다.");
|
|
1026
|
+
for (const agent of agents) out(`${agent.slug}\t${agent.name || agent.nameEn || agent.slug}\t${agent.entityKind || "agent"}`);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (sub === "restore") {
|
|
1030
|
+
const flags = parseCloudFlags(args.slice(1));
|
|
1031
|
+
const slug = flags._[0];
|
|
1032
|
+
if (!slug) fail("usage: agentlas cloud restore <slug> [--json]");
|
|
1033
|
+
const result = await restoreOwnedCloudAgentCli(db, slug);
|
|
1034
|
+
if (flags.json) return out(JSON.stringify(result, null, 2));
|
|
1035
|
+
out(`✓ restored ${result.slug} from private Agent Cloud`);
|
|
1036
|
+
out(` hash: ${result.packageHash}`);
|
|
1037
|
+
if (result.localPath) out(` files: ${result.localPath}`);
|
|
1038
|
+
if (result.localStateWarning) out(` warning: ${result.localStateWarning}`);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
834
1041
|
if (sub === "delete" || sub === "unpublish") {
|
|
835
1042
|
const flags = parseCloudFlags(args.slice(1));
|
|
836
1043
|
const slug = flags._[0];
|
|
837
1044
|
if (!slug) fail(`usage: agentlas cloud ${sub} <slug> [--json]`);
|
|
838
|
-
const result = await deleteCloudAgentCli(slug);
|
|
1045
|
+
const result = await deleteCloudAgentCli(slug, { scope: flags.scope });
|
|
839
1046
|
out(flags.json ? JSON.stringify(result, null, 2) : `✓ deleted ${result.slug || slug}`);
|
|
1047
|
+
if (!flags.json && Array.isArray(result.localStateWarnings)) {
|
|
1048
|
+
for (const warning of result.localStateWarnings) out(` warning: ${warning}`);
|
|
1049
|
+
}
|
|
840
1050
|
return;
|
|
841
1051
|
}
|
|
842
1052
|
const cloudRuntime = require("./agentlas-cloud-runtime.cjs");
|
|
@@ -884,14 +1094,15 @@ async function cmdCloud(db, args, runtimeOverride) {
|
|
|
884
1094
|
return;
|
|
885
1095
|
}
|
|
886
1096
|
if (sub === "install") return cmdCloudInstall(db, args[1]);
|
|
887
|
-
if (sub !== "package" && sub !== "publish") fail("usage: agentlas cloud <
|
|
1097
|
+
if (sub !== "package" && sub !== "save" && sub !== "publish") fail("usage: agentlas cloud <save|publish|package|list|restore|install|delete> ...");
|
|
888
1098
|
const flags = parseCloudFlags(args.slice(1));
|
|
889
1099
|
const root = flags._[0];
|
|
890
1100
|
if (!root) fail(`usage: agentlas cloud ${sub} <path>`);
|
|
1101
|
+
const visibility = cloudVisibilityForAction(sub, flags);
|
|
891
1102
|
const dryRun = sub === "package" || Boolean(flags["dry-run"]);
|
|
892
1103
|
const result = await packageCloudAgentCli(db, root, {
|
|
893
1104
|
slug: typeof flags.slug === "string" ? flags.slug : undefined,
|
|
894
|
-
visibility
|
|
1105
|
+
visibility,
|
|
895
1106
|
llmReview: Boolean(flags["llm-review"]),
|
|
896
1107
|
dryRun,
|
|
897
1108
|
runtimeOverride,
|
|
@@ -901,51 +1112,74 @@ async function cmdCloud(db, args, runtimeOverride) {
|
|
|
901
1112
|
return;
|
|
902
1113
|
}
|
|
903
1114
|
printCloudPackageResult(result);
|
|
904
|
-
if (sub === "publish" && result.status === "blocked") process.exit(1);
|
|
1115
|
+
if ((sub === "save" || sub === "publish") && result.status === "blocked") process.exit(1);
|
|
905
1116
|
}
|
|
906
1117
|
|
|
907
1118
|
async function packageCloudAgentCli(db, root, opts) {
|
|
908
|
-
const
|
|
1119
|
+
const requestedRoot = path.resolve(root);
|
|
909
1120
|
let st;
|
|
910
|
-
try { st = fs.
|
|
911
|
-
if (!st.isDirectory())
|
|
1121
|
+
try { st = fs.lstatSync(requestedRoot); } catch { throw new Error(`폴더를 찾을 수 없습니다: ${root}`); }
|
|
1122
|
+
if (!st.isDirectory() || st.isSymbolicLink()) throw new Error(`실제 폴더가 아닙니다: ${root}`);
|
|
1123
|
+
const rootPath = fs.realpathSync.native(requestedRoot);
|
|
1124
|
+
const visibility = opts.visibility || "private-link";
|
|
1125
|
+
const isPublicHubPublish = visibility === "marketplace";
|
|
912
1126
|
const scan = scanCloudFolderCli(rootPath);
|
|
913
|
-
|
|
1127
|
+
let snapshot = cloudPackageSnapshot(scan.included);
|
|
1128
|
+
let careerGraph;
|
|
1129
|
+
if (isPublicHubPublish) {
|
|
1130
|
+
careerGraph = cloudReadPublicCareerCard(snapshot, scan.findings);
|
|
1131
|
+
cloudReplacePublicCareerCard(scan, careerGraph);
|
|
1132
|
+
snapshot = cloudPackageSnapshot(scan.included);
|
|
1133
|
+
}
|
|
1134
|
+
const routingCard = isPublicHubPublish ? readCloudRoutingCardCli(snapshot) : {};
|
|
914
1135
|
if (routingCard.finding) scan.findings.push(routingCard.finding);
|
|
915
|
-
const
|
|
916
|
-
const
|
|
917
|
-
const
|
|
1136
|
+
const packageFindings = isPublicHubPublish ? scan.findings : privateCloudSafetyFindingsCli(scan.findings);
|
|
1137
|
+
const name = cloudReadName(snapshot, path.basename(rootPath));
|
|
1138
|
+
const slug = cloudSlug(opts.slug || cloudReadStableSlug(snapshot) || name || path.basename(rootPath));
|
|
1139
|
+
const scope = cloudScopeForVisibility(visibility);
|
|
1140
|
+
const baseDescriptor = cloudBaseDescriptorForSourceCli(scan.localPackageMarker, rootPath, slug, scope);
|
|
1141
|
+
const packageHashVersion = CLOUD_PACKAGE_HASH_V2;
|
|
1142
|
+
const packageHash = cloudHashPackage(scan.included, packageHashVersion);
|
|
918
1143
|
const manifest = {
|
|
919
1144
|
version: "0.1",
|
|
920
1145
|
kind: "agentlas-cloud-agent",
|
|
921
1146
|
slug,
|
|
922
1147
|
name,
|
|
923
|
-
tagline: cloudReadTagline(
|
|
924
|
-
agentKind: cloudInferKind(
|
|
925
|
-
runtimeLabels:
|
|
926
|
-
visibility
|
|
927
|
-
|
|
1148
|
+
tagline: cloudReadTagline(snapshot),
|
|
1149
|
+
agentKind: cloudInferKind(snapshot),
|
|
1150
|
+
runtimeLabels: cloudDetectRuntimeLabels(snapshot),
|
|
1151
|
+
visibility,
|
|
1152
|
+
// Content-derived and host-independent. Never persist an absolute local
|
|
1153
|
+
// path fingerprint into a portable Cloud package.
|
|
1154
|
+
rootFingerprint: sha(`agentlas-package-root:${packageHash}`),
|
|
928
1155
|
packageHash,
|
|
1156
|
+
packageHashVersion,
|
|
929
1157
|
fileCount: scan.files.length,
|
|
930
1158
|
includedFileCount: scan.included.length,
|
|
931
1159
|
totalBytes: scan.included.reduce((sum, file) => sum + file.bytes, 0),
|
|
932
1160
|
createdAt: new Date().toISOString(),
|
|
933
|
-
billingMode: opts.llmReview ? "submitter-local-runtime" : "static-only",
|
|
934
|
-
costOwner: opts.llmReview ? "submitter" : "none",
|
|
935
|
-
security: cloudSecuritySummary(
|
|
1161
|
+
billingMode: isPublicHubPublish && opts.llmReview ? "submitter-local-runtime" : "static-only",
|
|
1162
|
+
costOwner: isPublicHubPublish && opts.llmReview ? "submitter" : "none",
|
|
1163
|
+
security: cloudSecuritySummary(packageFindings),
|
|
1164
|
+
...(careerGraph ? { careerGraph } : {}),
|
|
936
1165
|
};
|
|
937
1166
|
if (routingCard.card) manifest.routingCard = routingCard.card;
|
|
938
1167
|
const packageDir = cloudPackageDir(slug);
|
|
939
1168
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
940
1169
|
const manifestPath = path.join(packageDir, "package.manifest.json");
|
|
941
1170
|
const bundlePath = path.join(packageDir, "package.bundle.json");
|
|
942
|
-
const bundle = {
|
|
1171
|
+
const bundle = {
|
|
1172
|
+
manifest,
|
|
1173
|
+
files: scan.included,
|
|
1174
|
+
source: { packagedBy: "agentlas-cli", packagedAt: manifest.createdAt, costOwner: manifest.costOwner },
|
|
1175
|
+
...(careerGraph ? { careerGraph } : {}),
|
|
1176
|
+
};
|
|
943
1177
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
944
1178
|
fs.writeFileSync(bundlePath, JSON.stringify(bundle, null, 2) + "\n", "utf8");
|
|
945
|
-
const review = opts.llmReview
|
|
946
|
-
? await runCloudLocalReviewCli(db, rootPath, manifest,
|
|
947
|
-
: cloudStaticReview(
|
|
948
|
-
const allFindings = [...
|
|
1179
|
+
const review = isPublicHubPublish && opts.llmReview
|
|
1180
|
+
? await runCloudLocalReviewCli(db, rootPath, manifest, packageFindings, opts.runtimeOverride)
|
|
1181
|
+
: cloudStaticReview(packageFindings, isPublicHubPublish ? "hub-public" : "owner-private");
|
|
1182
|
+
const allFindings = [...packageFindings, ...review.findings.filter((f) => !packageFindings.some((s) => s.id === f.id))];
|
|
949
1183
|
manifest.security = cloudSecuritySummary(allFindings);
|
|
950
1184
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
|
951
1185
|
fs.writeFileSync(bundlePath, JSON.stringify({ ...bundle, manifest }, null, 2) + "\n", "utf8");
|
|
@@ -953,7 +1187,34 @@ async function packageCloudAgentCli(db, root, opts) {
|
|
|
953
1187
|
let registration = null;
|
|
954
1188
|
let status = blocked ? "blocked" : opts.dryRun ? "dry-run" : "ready";
|
|
955
1189
|
if (!blocked && !opts.dryRun) {
|
|
956
|
-
registration = await registerCloudAgentCli(manifest, bundlePath, review,
|
|
1190
|
+
registration = await registerCloudAgentCli(manifest, bundlePath, review, visibility, { baseDescriptor });
|
|
1191
|
+
let descriptor;
|
|
1192
|
+
try {
|
|
1193
|
+
descriptor = rememberCloudAssetDescriptorCli(registration, { sourceRoot: rootPath });
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
const stateError = new Error(
|
|
1196
|
+
`Cloud save committed on the server, but this machine could not persist revision ${registration.revision}. ` +
|
|
1197
|
+
"Do not retry blindly; run `agentlas cloud list` and restore the asset before the next update. " +
|
|
1198
|
+
`Local state error: ${error.message || error}`,
|
|
1199
|
+
);
|
|
1200
|
+
stateError.code = "AGENTLAS_CLOUD_LOCAL_STATE_COMMIT_FAILED";
|
|
1201
|
+
stateError.receipt = registration;
|
|
1202
|
+
throw stateError;
|
|
1203
|
+
}
|
|
1204
|
+
try {
|
|
1205
|
+
writeCloudSourceMarkerCli(rootPath, scan, descriptor, {
|
|
1206
|
+
previousMarker: scan.localPackageMarker,
|
|
1207
|
+
packageHash,
|
|
1208
|
+
packageHashVersion,
|
|
1209
|
+
fileCount: scan.included.length,
|
|
1210
|
+
totalBytes: manifest.totalBytes,
|
|
1211
|
+
executablePaths: packageHashVersion === CLOUD_PACKAGE_HASH_V2
|
|
1212
|
+
? scan.included.filter((file) => file.executable).map((file) => file.path).sort()
|
|
1213
|
+
: undefined,
|
|
1214
|
+
});
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
registration.localStateWarning = `Cloud save succeeded, but the source marker could not be updated: ${error.message || error}`;
|
|
1217
|
+
}
|
|
957
1218
|
status = "registered";
|
|
958
1219
|
}
|
|
959
1220
|
return {
|
|
@@ -966,83 +1227,466 @@ async function packageCloudAgentCli(db, root, opts) {
|
|
|
966
1227
|
files: scan.files,
|
|
967
1228
|
review,
|
|
968
1229
|
registration,
|
|
969
|
-
summary: status === "registered"
|
|
1230
|
+
summary: status === "registered"
|
|
1231
|
+
? isPublicHubPublish
|
|
1232
|
+
? `Published ${slug} publicly to Agentlas Hub.`
|
|
1233
|
+
: `Saved ${slug} privately in Agent Cloud.`
|
|
1234
|
+
: status === "blocked"
|
|
1235
|
+
? isPublicHubPublish
|
|
1236
|
+
? `Hub publish blocked: ${review.summary}`
|
|
1237
|
+
: `Private Agent Cloud save blocked: ${review.summary}`
|
|
1238
|
+
: isPublicHubPublish
|
|
1239
|
+
? `Hub package ready: ${slug}.`
|
|
1240
|
+
: `Private Agent Cloud package ready: ${slug}.`,
|
|
970
1241
|
};
|
|
971
1242
|
}
|
|
972
1243
|
|
|
1244
|
+
function cloudScopeForVisibility(visibility) {
|
|
1245
|
+
return visibility === "marketplace" ? "hub-public" : "owner-private";
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function normalizeCloudScopeFlagCli(value) {
|
|
1249
|
+
if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
|
|
1250
|
+
if (value === "hub-public" || value === "marketplace" || value === "public") return "hub-public";
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function cloudRevisionEtag(revision) {
|
|
1255
|
+
return `"${revision}"`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function normalizeCloudAssetDescriptorCli(value, label = "cloud asset descriptor") {
|
|
1259
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1260
|
+
throw new Error(`${label} is missing`);
|
|
1261
|
+
}
|
|
1262
|
+
const cloudId = typeof value.cloudId === "string" ? value.cloudId.trim() : "";
|
|
1263
|
+
const slug = typeof value.slug === "string" ? value.slug.trim() : "";
|
|
1264
|
+
const scope = value.scope;
|
|
1265
|
+
const packageHash = String(value.packageHash || "").replace(/^sha256:/i, "").toLowerCase();
|
|
1266
|
+
const packageHashVersion = cloudPackageHashVersion(value.packageHashVersion);
|
|
1267
|
+
const revision = typeof value.revision === "string" ? value.revision : "";
|
|
1268
|
+
const etag = typeof value.etag === "string" ? value.etag : cloudRevisionEtag(revision);
|
|
1269
|
+
const updatedAt = typeof value.updatedAt === "string"
|
|
1270
|
+
? value.updatedAt
|
|
1271
|
+
: typeof value.savedAt === "string"
|
|
1272
|
+
? value.savedAt
|
|
1273
|
+
: typeof value.registeredAt === "string"
|
|
1274
|
+
? value.registeredAt
|
|
1275
|
+
: "";
|
|
1276
|
+
if (!/^[A-Za-z0-9_-]{8,128}$/.test(cloudId)) {
|
|
1277
|
+
throw new Error(`${label} cloudId is invalid`);
|
|
1278
|
+
}
|
|
1279
|
+
if (!slug || cloudSlug(slug) !== slug) throw new Error(`${label} slug is invalid`);
|
|
1280
|
+
if (!CLOUD_ASSET_SCOPES.has(scope)) throw new Error(`${label} scope is invalid`);
|
|
1281
|
+
if (!/^[a-f0-9]{64}$/.test(packageHash) || !packageHashVersion) {
|
|
1282
|
+
throw new Error(`${label} package identity is invalid`);
|
|
1283
|
+
}
|
|
1284
|
+
if (!revision || revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(revision)) {
|
|
1285
|
+
throw new Error(`${label} revision is invalid`);
|
|
1286
|
+
}
|
|
1287
|
+
if (etag !== cloudRevisionEtag(revision)) throw new Error(`${label} ETag does not authenticate revision`);
|
|
1288
|
+
if (!updatedAt || !Number.isFinite(Date.parse(updatedAt))) throw new Error(`${label} updatedAt is invalid`);
|
|
1289
|
+
return { cloudId, slug, scope, packageHash, packageHashVersion, revision, etag, updatedAt };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function cloudDescriptorKey(descriptor) {
|
|
1293
|
+
return `${descriptor.scope}:${descriptor.slug}`;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function cloudAssetStatePathCli() {
|
|
1297
|
+
return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function readCloudAssetStateCli() {
|
|
1301
|
+
const statePath = cloudAssetStatePathCli();
|
|
1302
|
+
if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
|
|
1303
|
+
let fd;
|
|
1304
|
+
try {
|
|
1305
|
+
fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
1306
|
+
const stat = fs.fstatSync(fd);
|
|
1307
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("state file is not a bounded regular file");
|
|
1308
|
+
const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
1309
|
+
if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
|
|
1310
|
+
throw new Error("state schema is invalid");
|
|
1311
|
+
}
|
|
1312
|
+
const assets = {};
|
|
1313
|
+
for (const [key, raw] of Object.entries(parsed.assets)) {
|
|
1314
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
|
|
1315
|
+
const descriptor = normalizeCloudAssetDescriptorCli(raw.descriptor, `state entry ${key}`);
|
|
1316
|
+
if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
|
|
1317
|
+
const sourceRoots = Array.isArray(raw.sourceRoots)
|
|
1318
|
+
? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
|
|
1319
|
+
: [];
|
|
1320
|
+
assets[key] = { descriptor, sourceRoots };
|
|
1321
|
+
}
|
|
1322
|
+
const deletedBases = Array.isArray(parsed.deletedBases)
|
|
1323
|
+
? parsed.deletedBases.filter((item) =>
|
|
1324
|
+
item && typeof item === "object" && !Array.isArray(item) &&
|
|
1325
|
+
typeof item.rootPath === "string" && path.isAbsolute(item.rootPath) &&
|
|
1326
|
+
typeof item.slug === "string" && cloudSlug(item.slug) === item.slug &&
|
|
1327
|
+
CLOUD_ASSET_SCOPES.has(item.scope) && typeof item.cloudId === "string" &&
|
|
1328
|
+
typeof item.revision === "string"
|
|
1329
|
+
).map((item) => ({
|
|
1330
|
+
rootPath: path.resolve(item.rootPath),
|
|
1331
|
+
slug: item.slug,
|
|
1332
|
+
scope: item.scope,
|
|
1333
|
+
cloudId: item.cloudId,
|
|
1334
|
+
revision: item.revision,
|
|
1335
|
+
})).slice(-256)
|
|
1336
|
+
: [];
|
|
1337
|
+
return { schemaVersion: 1, assets, deletedBases };
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
|
|
1340
|
+
} finally {
|
|
1341
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ }
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function writeCloudAssetStateCli(state) {
|
|
1346
|
+
const statePath = cloudAssetStatePathCli();
|
|
1347
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
1348
|
+
const temp = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1349
|
+
const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
1350
|
+
try {
|
|
1351
|
+
fs.writeFileSync(fd, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
1352
|
+
fs.fsyncSync(fd);
|
|
1353
|
+
} finally {
|
|
1354
|
+
fs.closeSync(fd);
|
|
1355
|
+
}
|
|
1356
|
+
fs.renameSync(temp, statePath);
|
|
1357
|
+
cloudApplyPortableFileMode(statePath, 0o600);
|
|
1358
|
+
cloudFsyncDirectoryCli(path.dirname(statePath));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function rememberCloudAssetDescriptorCli(value, options = {}) {
|
|
1362
|
+
const descriptor = normalizeCloudAssetDescriptorCli(value);
|
|
1363
|
+
const state = readCloudAssetStateCli();
|
|
1364
|
+
const key = cloudDescriptorKey(descriptor);
|
|
1365
|
+
const previous = state.assets[key];
|
|
1366
|
+
const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
|
|
1367
|
+
const roots = sameRevision ? [...previous.sourceRoots] : [];
|
|
1368
|
+
if (options.sourceRoot) {
|
|
1369
|
+
const sourceRoot = path.resolve(options.sourceRoot);
|
|
1370
|
+
roots.push(sourceRoot);
|
|
1371
|
+
state.deletedBases = state.deletedBases.filter(
|
|
1372
|
+
(item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
|
|
1376
|
+
writeCloudAssetStateCli(state);
|
|
1377
|
+
return descriptor;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function findCloudAssetDescriptorCli(slug, scope) {
|
|
1381
|
+
const safeSlug = cloudSlug(slug);
|
|
1382
|
+
const state = readCloudAssetStateCli();
|
|
1383
|
+
const matches = Object.values(state.assets).filter(
|
|
1384
|
+
(entry) => entry.descriptor.slug === safeSlug && (!scope || entry.descriptor.scope === scope),
|
|
1385
|
+
);
|
|
1386
|
+
if (!scope && matches.length > 1) {
|
|
1387
|
+
throw new Error(`Cloud asset ${safeSlug} exists in multiple scopes. Retry with --scope owner-private or --scope hub-public.`);
|
|
1388
|
+
}
|
|
1389
|
+
return matches.length === 1 ? matches[0] : null;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function cloudMarkerDescriptorsCli(marker) {
|
|
1393
|
+
const descriptors = {};
|
|
1394
|
+
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return descriptors;
|
|
1395
|
+
if (marker.cloudAssets && typeof marker.cloudAssets === "object" && !Array.isArray(marker.cloudAssets)) {
|
|
1396
|
+
for (const scope of CLOUD_ASSET_SCOPES) {
|
|
1397
|
+
if (!marker.cloudAssets[scope]) continue;
|
|
1398
|
+
try {
|
|
1399
|
+
const descriptor = normalizeCloudAssetDescriptorCli(marker.cloudAssets[scope], `local marker ${scope}`);
|
|
1400
|
+
if (descriptor.scope === scope) descriptors[scope] = descriptor;
|
|
1401
|
+
} catch { /* legacy or corrupt CAS entry is not adopted as a base revision */ }
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (marker.revision && marker.cloudId && marker.scope) {
|
|
1405
|
+
try {
|
|
1406
|
+
const descriptor = normalizeCloudAssetDescriptorCli(marker, "local marker");
|
|
1407
|
+
if (!descriptors[descriptor.scope]) descriptors[descriptor.scope] = descriptor;
|
|
1408
|
+
} catch { /* legacy marker */ }
|
|
1409
|
+
}
|
|
1410
|
+
return descriptors;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function cloudBaseDescriptorFromMarkerCli(marker, slug, scope) {
|
|
1414
|
+
const descriptor = cloudMarkerDescriptorsCli(marker)[scope];
|
|
1415
|
+
return descriptor && descriptor.slug === slug ? descriptor : null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function cloudBaseDescriptorForSourceCli(marker, rootPath, slug, scope) {
|
|
1419
|
+
const state = readCloudAssetStateCli();
|
|
1420
|
+
const normalizedRoot = path.resolve(rootPath);
|
|
1421
|
+
let markerDescriptor = cloudBaseDescriptorFromMarkerCli(marker, slug, scope);
|
|
1422
|
+
if (markerDescriptor && state.deletedBases.some((item) =>
|
|
1423
|
+
item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope &&
|
|
1424
|
+
item.cloudId === markerDescriptor.cloudId && item.revision === markerDescriptor.revision
|
|
1425
|
+
)) {
|
|
1426
|
+
markerDescriptor = null;
|
|
1427
|
+
}
|
|
1428
|
+
const entry = state.assets[`${scope}:${slug}`];
|
|
1429
|
+
const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
|
|
1430
|
+
if (!markerDescriptor) return stateDescriptor;
|
|
1431
|
+
if (!stateDescriptor) return markerDescriptor;
|
|
1432
|
+
return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
|
|
1433
|
+
? stateDescriptor
|
|
1434
|
+
: markerDescriptor;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function writeCloudSourceMarkerCli(rootPath, scan, descriptor, options = {}) {
|
|
1438
|
+
const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
|
|
1439
|
+
if (fs.existsSync(markerPath)) {
|
|
1440
|
+
const stat = fs.lstatSync(markerPath);
|
|
1441
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Agent Cloud revision marker is not a regular file");
|
|
1442
|
+
}
|
|
1443
|
+
const descriptors = cloudMarkerDescriptorsCli(options.previousMarker);
|
|
1444
|
+
if (descriptor) descriptors[descriptor.scope] = descriptor;
|
|
1445
|
+
if (options.removeDescriptor) {
|
|
1446
|
+
const current = descriptors[options.removeDescriptor.scope];
|
|
1447
|
+
if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
|
|
1448
|
+
delete descriptors[options.removeDescriptor.scope];
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
const latest = descriptor || Object.values(descriptors)[0] || null;
|
|
1452
|
+
const marker = {
|
|
1453
|
+
schemaVersion: 1,
|
|
1454
|
+
source: "agentlas-cloud",
|
|
1455
|
+
slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
|
|
1456
|
+
packageHash: descriptor?.packageHash || options.packageHash || options.previousMarker?.packageHash || "",
|
|
1457
|
+
packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || options.previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
|
|
1458
|
+
fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (options.previousMarker?.fileCount || 0),
|
|
1459
|
+
totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (options.previousMarker?.totalBytes || 0),
|
|
1460
|
+
executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : options.previousMarker?.executablePaths,
|
|
1461
|
+
cloudAssets: descriptors,
|
|
1462
|
+
...(latest ? latest : {}),
|
|
1463
|
+
restoredAt: options.previousMarker?.restoredAt,
|
|
1464
|
+
savedAt: new Date().toISOString(),
|
|
1465
|
+
};
|
|
1466
|
+
for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
|
|
1467
|
+
const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
|
|
1468
|
+
const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
1469
|
+
try {
|
|
1470
|
+
fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
|
|
1471
|
+
fs.fsyncSync(fd);
|
|
1472
|
+
} finally {
|
|
1473
|
+
fs.closeSync(fd);
|
|
1474
|
+
}
|
|
1475
|
+
fs.renameSync(temp, markerPath);
|
|
1476
|
+
cloudApplyPortableFileMode(markerPath, 0o600);
|
|
1477
|
+
cloudFsyncDirectoryCli(rootPath);
|
|
1478
|
+
return marker;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function readCloudSourceMarkerCli(rootPath) {
|
|
1482
|
+
const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
|
|
1483
|
+
if (!fs.existsSync(markerPath)) return null;
|
|
1484
|
+
let fd;
|
|
1485
|
+
try {
|
|
1486
|
+
fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
1487
|
+
const stat = fs.fstatSync(fd);
|
|
1488
|
+
if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("marker is not a bounded regular file");
|
|
1489
|
+
return JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
1490
|
+
} finally {
|
|
1491
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
973
1495
|
function scanCloudFolderCli(rootPath) {
|
|
974
1496
|
const files = [];
|
|
975
1497
|
const included = [];
|
|
976
1498
|
const findings = [];
|
|
1499
|
+
const restoredExecutablePaths = cloudReadRestoreExecutablePaths(rootPath);
|
|
1500
|
+
let localPackageMarker = null;
|
|
977
1501
|
let totalBytes = 0;
|
|
978
1502
|
let count = 0;
|
|
979
1503
|
let hasDefinition = false;
|
|
980
1504
|
function addFinding(kind, severity, category, message, file, remediation) {
|
|
981
1505
|
findings.push({ id: `${kind}-${sha(file || message).slice(0, 10)}`, severity, category, message, ...(file ? { file } : {}), ...(remediation ? { remediation } : {}) });
|
|
982
1506
|
}
|
|
1507
|
+
function insideRoot(candidate) {
|
|
1508
|
+
const relative = path.relative(rootPath, candidate);
|
|
1509
|
+
return relative === "" || (relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
1510
|
+
}
|
|
1511
|
+
function readStableFile(file, rel) {
|
|
1512
|
+
const beforeReal = fs.realpathSync.native(file);
|
|
1513
|
+
if (!insideRoot(beforeReal)) throw new Error("file resolves outside the approved package root");
|
|
1514
|
+
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
1515
|
+
const nonBlock = fs.constants.O_NONBLOCK || 0;
|
|
1516
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow | nonBlock);
|
|
1517
|
+
try {
|
|
1518
|
+
const before = fs.fstatSync(fd);
|
|
1519
|
+
if (!before.isFile()) throw new Error("package entry is not a regular file");
|
|
1520
|
+
if (before.size > CLOUD_MAX_FILE_BYTES) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
|
|
1521
|
+
const chunks = [];
|
|
1522
|
+
let actualBytes = 0;
|
|
1523
|
+
for (;;) {
|
|
1524
|
+
const capacity = Math.min(64 * 1024, CLOUD_MAX_FILE_BYTES + 1 - actualBytes);
|
|
1525
|
+
if (capacity <= 0) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
|
|
1526
|
+
const chunk = Buffer.allocUnsafe(capacity);
|
|
1527
|
+
const read = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
1528
|
+
if (read === 0) break;
|
|
1529
|
+
actualBytes += read;
|
|
1530
|
+
if (actualBytes > CLOUD_MAX_FILE_BYTES) throw new Error(`file exceeds ${CLOUD_MAX_FILE_BYTES} bytes`);
|
|
1531
|
+
chunks.push(chunk.subarray(0, read));
|
|
1532
|
+
}
|
|
1533
|
+
const after = fs.fstatSync(fd);
|
|
1534
|
+
const afterReal = fs.realpathSync.native(file);
|
|
1535
|
+
const pathStat = fs.statSync(file);
|
|
1536
|
+
if (
|
|
1537
|
+
!insideRoot(afterReal) || beforeReal !== afterReal ||
|
|
1538
|
+
before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size ||
|
|
1539
|
+
before.mode !== after.mode || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs ||
|
|
1540
|
+
after.dev !== pathStat.dev || after.ino !== pathStat.ino || after.mode !== pathStat.mode ||
|
|
1541
|
+
actualBytes !== after.size
|
|
1542
|
+
) {
|
|
1543
|
+
throw new Error("package entry changed while it was being read");
|
|
1544
|
+
}
|
|
1545
|
+
return {
|
|
1546
|
+
bytes: Buffer.concat(chunks, actualBytes),
|
|
1547
|
+
executable: cloudPortableExecutableForFile(rel, after.mode, restoredExecutablePaths),
|
|
1548
|
+
};
|
|
1549
|
+
} finally {
|
|
1550
|
+
fs.closeSync(fd);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
983
1553
|
function walk(dir) {
|
|
984
|
-
|
|
1554
|
+
let directoryBefore;
|
|
1555
|
+
let directoryRealBefore;
|
|
1556
|
+
try {
|
|
1557
|
+
directoryBefore = fs.lstatSync(dir);
|
|
1558
|
+
directoryRealBefore = fs.realpathSync.native(dir);
|
|
1559
|
+
if (!directoryBefore.isDirectory() || directoryBefore.isSymbolicLink() || !insideRoot(directoryRealBefore)) {
|
|
1560
|
+
throw new Error("directory is not stable inside the approved root");
|
|
1561
|
+
}
|
|
1562
|
+
} catch (error) {
|
|
1563
|
+
addFinding("unsafe-directory", "blocker", "policy", `Package directory could not be read safely: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Remove linked or changing directories and retry.");
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
let entries;
|
|
1567
|
+
try {
|
|
1568
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
addFinding("unsafe-directory", "blocker", "policy", `Package directory could not be read safely: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Remove linked or changing directories and retry.");
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
985
1573
|
for (const entry of entries) {
|
|
986
1574
|
if (entry.name.startsWith("._")) continue;
|
|
987
1575
|
const abs = path.join(dir, entry.name);
|
|
988
1576
|
const rel = path.relative(rootPath, abs).split(path.sep).join("/");
|
|
1577
|
+
if (cloudPortablePathKey(rel) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
|
|
1578
|
+
// Local restore/CAS metadata is runtime state, never portable asset
|
|
1579
|
+
// data, but it must be captured with the same no-follow stability gate.
|
|
1580
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
1581
|
+
addFinding("unsafe-local-state", "blocker", "policy", "Agent Cloud local revision marker must be a stable regular file.", rel, "Remove the linked or special marker and restore/list the asset again.");
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
const stableMarker = readStableFile(abs, rel);
|
|
1586
|
+
localPackageMarker = JSON.parse(stableMarker.bytes.toString("utf8"));
|
|
1587
|
+
} catch (error) {
|
|
1588
|
+
addFinding("invalid-local-state", "blocker", "policy", `Agent Cloud local revision marker could not be read safely: ${error.message || error}`, rel, "Repair or remove the marker, then restore/list the asset again.");
|
|
1589
|
+
}
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
989
1592
|
if (entry.isSymbolicLink()) {
|
|
990
1593
|
addFinding("symlink", "blocker", "policy", "Symbolic links are not allowed in cloud agent packages.", rel, "Replace the symlink with an ordinary file or remove it.");
|
|
991
1594
|
files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "symlink-blocked" });
|
|
992
1595
|
continue;
|
|
993
1596
|
}
|
|
994
1597
|
if (entry.isDirectory()) {
|
|
995
|
-
if (
|
|
1598
|
+
if (CLOUD_SKIP_DIRS.has(entry.name)) continue;
|
|
1599
|
+
walk(abs);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
if (!entry.isFile()) {
|
|
1603
|
+
addFinding("unsupported-entry", "blocker", "policy", "Only stable ordinary files and directories are allowed in Cloud packages.", rel, "Remove sockets, FIFOs, devices, and other special filesystem entries.");
|
|
1604
|
+
files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "unsupported-entry" });
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (!cloudPortableRelativePath(rel)) {
|
|
1608
|
+
addFinding("unsafe-path", "blocker", "policy", "File path is not portable across supported hosts.", rel, "Rename the file to a Unicode NFC, relative, cross-platform-safe path.");
|
|
1609
|
+
files.push({ path: rel, bytes: 0, sha256: "", kind: "binary", included: false, reason: "unsafe-path" });
|
|
996
1610
|
continue;
|
|
997
1611
|
}
|
|
998
|
-
if (!entry.isFile()) continue;
|
|
999
1612
|
count++;
|
|
1000
1613
|
if (count > CLOUD_MAX_FILES) {
|
|
1001
1614
|
addFinding("file-count-limit", "blocker", "size", `Package has more than ${CLOUD_MAX_FILES} files.`, "", "Publish a focused agent/team folder.");
|
|
1002
1615
|
continue;
|
|
1003
1616
|
}
|
|
1004
1617
|
if (CLOUD_AGENT_FILES.has(entry.name)) hasDefinition = true;
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const digest = sha(fs.readFileSync(abs));
|
|
1618
|
+
let hint;
|
|
1619
|
+
try { hint = fs.lstatSync(abs); } catch { hint = { size: 0 }; }
|
|
1008
1620
|
if (CLOUD_BLOCKED_FILE_RE.some((re) => re.test(entry.name))) {
|
|
1009
1621
|
addFinding("blocked-file", "blocker", "secret", "Secret-bearing file names are not allowed in cloud packages.", rel, "Remove credentials and publish only env key names.");
|
|
1010
|
-
files.push({ path: rel, bytes:
|
|
1622
|
+
files.push({ path: rel, bytes: Number(hint.size) || 0, sha256: "", kind: "binary", included: false, reason: "secret-file-blocked" });
|
|
1011
1623
|
continue;
|
|
1012
1624
|
}
|
|
1013
|
-
if (
|
|
1014
|
-
addFinding("large-file", "
|
|
1015
|
-
files.push({ path: rel, bytes:
|
|
1625
|
+
if (Number(hint.size) > CLOUD_MAX_FILE_BYTES) {
|
|
1626
|
+
addFinding("large-file", "blocker", "size", `File exceeds ${CLOUD_MAX_FILE_BYTES} bytes.`, rel, "Move large assets out of the package.");
|
|
1627
|
+
files.push({ path: rel, bytes: Number(hint.size), sha256: "", kind: "binary", included: false, reason: "file-too-large" });
|
|
1016
1628
|
continue;
|
|
1017
1629
|
}
|
|
1018
1630
|
const ext = path.extname(entry.name).toLowerCase();
|
|
1019
1631
|
const isText = CLOUD_TEXT_EXTS.has(ext) || CLOUD_AGENT_FILES.has(entry.name);
|
|
1020
|
-
|
|
1021
|
-
|
|
1632
|
+
let stable;
|
|
1633
|
+
try {
|
|
1634
|
+
stable = readStableFile(abs, rel);
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
addFinding("unstable-file", "blocker", "policy", `Package file could not be read safely: ${error.message || error}`, rel, "Remove linked or concurrently changing files and retry.");
|
|
1637
|
+
files.push({ path: rel, bytes: Number(hint.size) || 0, sha256: "", kind: isText ? "text" : "binary", included: false, reason: "unstable-file" });
|
|
1022
1638
|
continue;
|
|
1023
1639
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1640
|
+
const content = stable.bytes;
|
|
1641
|
+
const executable = stable.executable;
|
|
1642
|
+
totalBytes += content.length;
|
|
1643
|
+
const digest = sha(content);
|
|
1644
|
+
cloudAddSecretFindingsFromBytes(content, rel, addFinding);
|
|
1645
|
+
if (isText) {
|
|
1646
|
+
const decoded = cloudDecodeTextAsset(content);
|
|
1647
|
+
if (!decoded.ok) {
|
|
1648
|
+
addFinding("invalid-text-encoding", "blocker", "policy", "A text agent asset is not valid UTF-8 or BOM-marked UTF-16.", rel, "Save the file as UTF-8 or BOM-marked UTF-16 before packaging.");
|
|
1649
|
+
files.push({ path: rel, bytes: content.length, sha256: digest, kind: "text", executable, included: false, reason: "invalid-text-encoding" });
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
const text = decoded.text;
|
|
1653
|
+
if (/(?:curl|wget)[^\n|&;]+[|]\s*(?:sh|bash)/i.test(text)) {
|
|
1654
|
+
addFinding("curl-pipe-shell", "high", "network", "Remote shell install pattern detected.", rel, "Use explicit, reviewable install steps.");
|
|
1655
|
+
}
|
|
1027
1656
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1657
|
+
files.push({ path: rel, bytes: content.length, sha256: digest, kind: isText ? "text" : "binary", executable, included: true });
|
|
1658
|
+
included.push({ path: rel, bytes: content.length, sha256: digest, executable, contentBase64: content.toString("base64") });
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1661
|
+
const directoryAfter = fs.lstatSync(dir);
|
|
1662
|
+
const directoryRealAfter = fs.realpathSync.native(dir);
|
|
1663
|
+
if (
|
|
1664
|
+
!directoryAfter.isDirectory() || directoryAfter.isSymbolicLink() || !insideRoot(directoryRealAfter) ||
|
|
1665
|
+
directoryRealBefore !== directoryRealAfter || directoryBefore.dev !== directoryAfter.dev ||
|
|
1666
|
+
directoryBefore.ino !== directoryAfter.ino || directoryBefore.mtimeMs !== directoryAfter.mtimeMs ||
|
|
1667
|
+
directoryBefore.ctimeMs !== directoryAfter.ctimeMs
|
|
1668
|
+
) {
|
|
1669
|
+
throw new Error("directory changed while it was scanned");
|
|
1030
1670
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
addFinding("unstable-directory", "blocker", "policy", `Package directory changed while it was scanned: ${error.message || error}`, path.relative(rootPath, dir).split(path.sep).join("/"), "Stop concurrent edits and retry.");
|
|
1033
1673
|
}
|
|
1034
1674
|
}
|
|
1035
1675
|
walk(rootPath);
|
|
1676
|
+
const pathConflict = cloudPortablePathConflict(included.map((file) => file.path));
|
|
1677
|
+
if (pathConflict) {
|
|
1678
|
+
addFinding(pathConflict.code, "blocker", "policy", pathConflict.message, "", "Rename aliased paths so every file and ancestor directory has one portable identity.");
|
|
1679
|
+
}
|
|
1036
1680
|
if (!hasDefinition) addFinding("missing-agent-definition", "blocker", "structure", "No agent definition file was found.", "", "Add AGENTS.md, CLAUDE.md, GEMINI.md, AGENT.md, or README.md at the package root.");
|
|
1037
1681
|
if (totalBytes > CLOUD_MAX_TOTAL_BYTES) addFinding("package-size-limit", "blocker", "size", `Package exceeds ${CLOUD_MAX_TOTAL_BYTES} bytes.`, "", "Publish a smaller agent folder.");
|
|
1038
|
-
files.sort(
|
|
1039
|
-
included.sort(
|
|
1040
|
-
return { files, included, findings, totalBytes };
|
|
1682
|
+
files.sort(cloudCodePointPathOrder);
|
|
1683
|
+
included.sort(cloudCodePointPathOrder);
|
|
1684
|
+
return { files, included, findings, totalBytes, localPackageMarker };
|
|
1041
1685
|
}
|
|
1042
1686
|
|
|
1043
|
-
function readCloudRoutingCardCli(
|
|
1044
|
-
const
|
|
1045
|
-
if (!
|
|
1687
|
+
function readCloudRoutingCardCli(snapshot) {
|
|
1688
|
+
const file = snapshot.get(CLOUD_ROUTING_CARD_PATH);
|
|
1689
|
+
if (!file) {
|
|
1046
1690
|
return {
|
|
1047
1691
|
finding: {
|
|
1048
1692
|
id: "routing-card-required",
|
|
@@ -1055,7 +1699,7 @@ function readCloudRoutingCardCli(rootPath) {
|
|
|
1055
1699
|
};
|
|
1056
1700
|
}
|
|
1057
1701
|
try {
|
|
1058
|
-
const parsed = JSON.parse(
|
|
1702
|
+
const parsed = JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8"));
|
|
1059
1703
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1060
1704
|
return cloudRoutingCardFinding("routing-card-invalid", "Routing card must be a JSON object.", "Replace .agentlas/routing-card.json with a routing-card/2.0 object.");
|
|
1061
1705
|
}
|
|
@@ -1100,14 +1744,25 @@ function cloudRoutingCardProblem(card) {
|
|
|
1100
1744
|
return null;
|
|
1101
1745
|
}
|
|
1102
1746
|
|
|
1103
|
-
function
|
|
1747
|
+
function privateCloudSafetyFindingsCli(findings) {
|
|
1748
|
+
return findings.filter((finding) =>
|
|
1749
|
+
(finding.severity === "blocker" && !finding.id.startsWith("missing-agent-definition"))
|
|
1750
|
+
|| finding.category === "secret"
|
|
1751
|
+
|| finding.category === "size");
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
function cloudStaticReview(findings, scope = "hub-public") {
|
|
1104
1755
|
const blockers = findings.filter((f) => f.severity === "blocker").length;
|
|
1105
1756
|
const high = findings.filter((f) => f.severity === "high").length;
|
|
1106
1757
|
return {
|
|
1107
1758
|
mode: "static-only",
|
|
1108
1759
|
verdict: blockers ? "fail" : high ? "needs-review" : "pass",
|
|
1109
1760
|
costOwner: "none",
|
|
1110
|
-
summary: blockers || high
|
|
1761
|
+
summary: blockers || high
|
|
1762
|
+
? `${blockers} blocker(s), ${high} high-risk finding(s).`
|
|
1763
|
+
: scope === "owner-private"
|
|
1764
|
+
? "Private Agent Cloud safety checks passed."
|
|
1765
|
+
: "Static public package review passed.",
|
|
1111
1766
|
findings,
|
|
1112
1767
|
reviewedAt: new Date().toISOString(),
|
|
1113
1768
|
};
|
|
@@ -1153,42 +1808,178 @@ async function runCloudLocalReviewCli(db, rootPath, manifest, staticFindings, ru
|
|
|
1153
1808
|
};
|
|
1154
1809
|
}
|
|
1155
1810
|
|
|
1156
|
-
|
|
1811
|
+
function cloudCasResponseErrorCli(response, label) {
|
|
1812
|
+
let body = null;
|
|
1813
|
+
try { body = JSON.parse(response.text || "null"); } catch { /* generic below */ }
|
|
1814
|
+
const code = body && typeof body.code === "string" ? body.code : "cloud_request_failed";
|
|
1815
|
+
let message = `${label} 실패 ${response.status}`;
|
|
1816
|
+
if (response.status === 412 && code === "cloud_agent_revision_conflict") {
|
|
1817
|
+
const current = body && body.current ? body.current : body && body.conflict && body.conflict.current;
|
|
1818
|
+
message = current
|
|
1819
|
+
? `다른 PC에서 이 Agent Cloud 자산이 변경되었습니다. 자동 덮어쓰기는 중단했습니다. \`agentlas cloud list\`로 최신 revision을 확인하고 \`agentlas cloud restore ${current.slug || "<slug>"}\`로 복원한 뒤 변경 사항을 병합하세요.`
|
|
1820
|
+
: "이 Agent Cloud 자산은 다른 PC에서 삭제되었거나 다른 식별자로 다시 생성되었습니다. 자동 재생성은 중단했습니다. `agentlas cloud list`로 현재 상태를 확인하세요.";
|
|
1821
|
+
} else if (response.status === 428 && code === "client_upgrade_required") {
|
|
1822
|
+
message = "기존 Cloud 자산을 안전하게 갱신할 base revision이 없습니다. 서버 revision을 자동 복사하지 않습니다. `agentlas cloud list`로 확인하고 `agentlas cloud restore <slug>`로 복원한 뒤 다시 저장하세요.";
|
|
1823
|
+
} else if (response.status === 503 && code === "cloud_mutations_maintenance") {
|
|
1824
|
+
const retryAfter = response.headers && typeof response.headers.get === "function" ? response.headers.get("retry-after") : null;
|
|
1825
|
+
message = `Agent Cloud 저장/삭제가 잠시 점검 중입니다${retryAfter ? ` (약 ${retryAfter}초 후 재시도)` : ""}. 읽기·목록·복원은 계속 사용할 수 있습니다.`;
|
|
1826
|
+
} else if (body && typeof body.error === "string") {
|
|
1827
|
+
message = `${label} 실패 ${response.status}: ${body.error.slice(0, 300)}`;
|
|
1828
|
+
}
|
|
1829
|
+
const error = new Error(message);
|
|
1830
|
+
error.code = code;
|
|
1831
|
+
error.status = response.status;
|
|
1832
|
+
if (body && body.current) error.current = body.current;
|
|
1833
|
+
if (body && body.conflict) error.conflict = body.conflict;
|
|
1834
|
+
return error;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
async function registerCloudAgentCli(manifest, bundlePath, review, visibility, options = {}) {
|
|
1157
1838
|
const cookie = await cloudSessionCookieCli();
|
|
1158
1839
|
if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
|
|
1159
1840
|
if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
|
|
1160
1841
|
const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
|
|
1161
1842
|
const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
|
|
1162
|
-
const
|
|
1843
|
+
const expectedScope = cloudScopeForVisibility(visibility);
|
|
1844
|
+
const baseDescriptor = options.baseDescriptor
|
|
1845
|
+
? normalizeCloudAssetDescriptorCli(options.baseDescriptor, "base revision")
|
|
1846
|
+
: null;
|
|
1847
|
+
if (baseDescriptor && (baseDescriptor.slug !== manifest.slug || baseDescriptor.scope !== expectedScope)) {
|
|
1848
|
+
throw new Error("Agent Cloud base revision does not match the requested slug/scope.");
|
|
1849
|
+
}
|
|
1850
|
+
const headers = { "content-type": "application/json", cookie, origin: base };
|
|
1851
|
+
if (baseDescriptor) {
|
|
1852
|
+
headers["if-match"] = baseDescriptor.etag;
|
|
1853
|
+
headers["x-agentlas-cloud-id"] = baseDescriptor.cloudId;
|
|
1854
|
+
} else {
|
|
1855
|
+
headers["if-none-match"] = "*";
|
|
1856
|
+
}
|
|
1857
|
+
const resp = await fetchHubCli(`${base}/api/cloud-agents/v1/register`, {
|
|
1163
1858
|
method: "POST",
|
|
1164
|
-
headers
|
|
1859
|
+
headers,
|
|
1165
1860
|
body: JSON.stringify({ manifest, bundle, review, visibility, billing: { modelCallsPaidBy: review.costOwner, localRuntime: review.runtimeLabel || null } }),
|
|
1166
1861
|
});
|
|
1167
|
-
if (!resp.ok)
|
|
1168
|
-
const json =
|
|
1862
|
+
if (!resp.ok) throw cloudCasResponseErrorCli(resp, "Agentlas Cloud 등록");
|
|
1863
|
+
const json = parseHubJsonCli(resp, "Agentlas Cloud 등록");
|
|
1864
|
+
const expectedSource = visibility === "marketplace" ? "hub" : "agent-cloud";
|
|
1865
|
+
const expectedVisibility = visibility === "marketplace" ? "marketplace" : "owner-private";
|
|
1866
|
+
const etag = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("etag") : null;
|
|
1867
|
+
const cacheControl = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("cache-control") : null;
|
|
1868
|
+
const expectedOperations = baseDescriptor ? new Set(["updated", "unchanged"]) : new Set(["created"]);
|
|
1869
|
+
if (
|
|
1870
|
+
json.schema !== "agentlas.agent_cloud.registration.v1" ||
|
|
1871
|
+
!expectedOperations.has(json.operation) ||
|
|
1872
|
+
json.source !== expectedSource ||
|
|
1873
|
+
json.visibility !== expectedVisibility ||
|
|
1874
|
+
json.scope !== expectedScope ||
|
|
1875
|
+
json.owner !== true ||
|
|
1876
|
+
json.publicHubPublished !== (visibility === "marketplace") ||
|
|
1877
|
+
json.dryRun !== false ||
|
|
1878
|
+
typeof json.cloudId !== "string" || !json.cloudId.trim() ||
|
|
1879
|
+
json.slug !== manifest.slug ||
|
|
1880
|
+
json.packageHash !== manifest.packageHash ||
|
|
1881
|
+
json.packageHashVersion !== manifest.packageHashVersion ||
|
|
1882
|
+
typeof json.revision !== "string" || etag !== cloudRevisionEtag(json.revision) ||
|
|
1883
|
+
typeof json.registeredAt !== "string" || !Number.isFinite(Date.parse(json.registeredAt)) ||
|
|
1884
|
+
!String(cacheControl || "").toLowerCase().includes("no-store") ||
|
|
1885
|
+
(baseDescriptor && json.cloudId !== baseDescriptor.cloudId)
|
|
1886
|
+
) {
|
|
1887
|
+
throw new Error("Agentlas Cloud register returned an invalid or mismatched registration receipt.");
|
|
1888
|
+
}
|
|
1889
|
+
const descriptor = normalizeCloudAssetDescriptorCli({
|
|
1890
|
+
cloudId: json.cloudId,
|
|
1891
|
+
slug: json.slug,
|
|
1892
|
+
scope: json.scope,
|
|
1893
|
+
packageHash: json.packageHash,
|
|
1894
|
+
packageHashVersion: json.packageHashVersion,
|
|
1895
|
+
revision: json.revision,
|
|
1896
|
+
etag,
|
|
1897
|
+
updatedAt: json.savedAt || json.registeredAt,
|
|
1898
|
+
}, "registration receipt");
|
|
1169
1899
|
return {
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
url: json.url,
|
|
1173
|
-
marketplaceUrl: json.marketplaceUrl,
|
|
1174
|
-
registeredAt: json.registeredAt
|
|
1900
|
+
...descriptor,
|
|
1901
|
+
operation: json.operation,
|
|
1902
|
+
...(typeof json.url === "string" ? { url: json.url } : {}),
|
|
1903
|
+
...(typeof json.marketplaceUrl === "string" ? { marketplaceUrl: json.marketplaceUrl } : {}),
|
|
1904
|
+
registeredAt: json.registeredAt,
|
|
1175
1905
|
dryRun: false,
|
|
1176
1906
|
};
|
|
1177
1907
|
}
|
|
1178
1908
|
|
|
1179
|
-
async function deleteCloudAgentCli(slug) {
|
|
1909
|
+
async function deleteCloudAgentCli(slug, options = {}) {
|
|
1180
1910
|
const safeSlug = String(slug || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1181
1911
|
if (!safeSlug) fail("usage: agentlas cloud delete <slug> [--json]");
|
|
1182
1912
|
const cookie = await cloudSessionCookieCli();
|
|
1183
1913
|
if (!cookie) fail("agentlas.cloud 로그인이 필요합니다. 데스크톱 앱에서 로그인하거나 AGENTLAS_SESSION을 설정하세요.");
|
|
1184
1914
|
if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
|
|
1915
|
+
const scope = options.scope == null ? null : normalizeCloudScopeFlagCli(options.scope);
|
|
1916
|
+
if (options.scope != null && !scope) throw new Error("--scope must be owner-private or hub-public");
|
|
1917
|
+
const localEntry = findCloudAssetDescriptorCli(safeSlug, scope);
|
|
1918
|
+
if (!localEntry) {
|
|
1919
|
+
throw new Error(`No observed base revision for ${safeSlug}${scope ? ` (${scope})` : ""}. Run \`agentlas cloud list\` first, then retry the exact asset deletion.`);
|
|
1920
|
+
}
|
|
1921
|
+
const descriptor = localEntry.descriptor;
|
|
1185
1922
|
const base = (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
|
|
1186
|
-
const
|
|
1923
|
+
const query = new URLSearchParams({ slug: safeSlug, scope: descriptor.scope, cloudId: descriptor.cloudId });
|
|
1924
|
+
const resp = await fetchHubCli(`${base}/api/cloud-agents/v1/register?${query.toString()}`, {
|
|
1187
1925
|
method: "DELETE",
|
|
1188
|
-
headers: {
|
|
1926
|
+
headers: {
|
|
1927
|
+
"content-type": "application/json",
|
|
1928
|
+
cookie,
|
|
1929
|
+
origin: base,
|
|
1930
|
+
"if-match": descriptor.etag,
|
|
1931
|
+
"x-agentlas-cloud-id": descriptor.cloudId,
|
|
1932
|
+
},
|
|
1189
1933
|
});
|
|
1190
|
-
if (!resp.ok)
|
|
1191
|
-
|
|
1934
|
+
if (!resp.ok) throw cloudCasResponseErrorCli(resp, "Agentlas Cloud 삭제");
|
|
1935
|
+
const json = parseHubJsonCli(resp, "Agentlas Cloud 삭제");
|
|
1936
|
+
const responseEtag = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("etag") : null;
|
|
1937
|
+
const cacheControl = resp.headers && typeof resp.headers.get === "function" ? resp.headers.get("cache-control") : null;
|
|
1938
|
+
const expectedSource = descriptor.scope === "hub-public" ? "hub" : "agent-cloud";
|
|
1939
|
+
const expectedVisibility = descriptor.scope === "hub-public" ? "marketplace" : "owner-private";
|
|
1940
|
+
const deletionTimestamp = descriptor.scope === "hub-public" ? json.unpublishedAt : json.deletedAt;
|
|
1941
|
+
if (
|
|
1942
|
+
json.schema !== "agentlas.agent_cloud.delete.v1" || json.ok !== true ||
|
|
1943
|
+
json.source !== expectedSource || json.visibility !== expectedVisibility ||
|
|
1944
|
+
json.scope !== descriptor.scope || json.cloudId !== descriptor.cloudId || json.slug !== descriptor.slug ||
|
|
1945
|
+
json.packageHash !== descriptor.packageHash || json.packageHashVersion !== descriptor.packageHashVersion ||
|
|
1946
|
+
json.revision !== descriptor.revision ||
|
|
1947
|
+
responseEtag !== descriptor.etag || !String(cacheControl || "").toLowerCase().includes("no-store") ||
|
|
1948
|
+
(descriptor.scope === "hub-public" && json.operation !== "unpublished") ||
|
|
1949
|
+
typeof deletionTimestamp !== "string" || !Number.isFinite(Date.parse(deletionTimestamp))
|
|
1950
|
+
) {
|
|
1951
|
+
throw new Error("Agentlas Cloud delete returned an invalid or mismatched deletion receipt.");
|
|
1952
|
+
}
|
|
1953
|
+
const state = readCloudAssetStateCli();
|
|
1954
|
+
const key = cloudDescriptorKey(descriptor);
|
|
1955
|
+
const roots = state.assets[key]?.sourceRoots || [];
|
|
1956
|
+
const warnings = [];
|
|
1957
|
+
for (const rootPath of roots) {
|
|
1958
|
+
state.deletedBases.push({ rootPath, slug: descriptor.slug, scope: descriptor.scope, cloudId: descriptor.cloudId, revision: descriptor.revision });
|
|
1959
|
+
}
|
|
1960
|
+
delete state.assets[key];
|
|
1961
|
+
state.deletedBases = state.deletedBases.slice(-256);
|
|
1962
|
+
try {
|
|
1963
|
+
writeCloudAssetStateCli(state);
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
const stateError = new Error(
|
|
1966
|
+
`Cloud delete committed on the server, but this machine could not persist the deletion tombstone. ` +
|
|
1967
|
+
"Run `agentlas cloud list` before saving this slug again. " +
|
|
1968
|
+
`Local state error: ${error.message || error}`,
|
|
1969
|
+
);
|
|
1970
|
+
stateError.code = "AGENTLAS_CLOUD_LOCAL_STATE_COMMIT_FAILED";
|
|
1971
|
+
stateError.receipt = json;
|
|
1972
|
+
throw stateError;
|
|
1973
|
+
}
|
|
1974
|
+
for (const rootPath of roots) {
|
|
1975
|
+
try {
|
|
1976
|
+
const marker = readCloudSourceMarkerCli(rootPath);
|
|
1977
|
+
if (marker) writeCloudSourceMarkerCli(rootPath, null, null, { previousMarker: marker, removeDescriptor: descriptor });
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
warnings.push(`Could not clear ${rootPath}: ${error.message || error}`);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return { ...json, ...(warnings.length ? { localStateWarnings: warnings } : {}) };
|
|
1192
1983
|
}
|
|
1193
1984
|
|
|
1194
1985
|
// `agentlas login`이 저장하는 CLI 세션 파일 (평문·0600 — 데스크탑의 safeStorage 파일과 별개).
|
|
@@ -1223,101 +2014,635 @@ async function cloudSessionCookieCli() {
|
|
|
1223
2014
|
async function cmdCloudInstall(db, slug) {
|
|
1224
2015
|
if (!slug) fail("usage: agentlas cloud install <slug>");
|
|
1225
2016
|
const listing = await fetchCloudManifestCli(slug);
|
|
1226
|
-
if (!listing) fail(`
|
|
2017
|
+
if (!listing) fail(`Hub agent를 찾을 수 없습니다: ${slug}`);
|
|
2018
|
+
if (listing.delivery && listing.delivery.mode === "call_only") {
|
|
2019
|
+
fail(`이 Hub 에이전트는 소스 설치가 허용되지 않은 call-only 자산입니다. 실행: agentlas call ${slug}`);
|
|
2020
|
+
}
|
|
1227
2021
|
const agent = persistCloudListingCli(db, listing);
|
|
1228
|
-
out(`✓ installed ${agent.slug} — ${agent.name}`);
|
|
2022
|
+
out(`✓ Hub installed ${agent.slug} — ${agent.name}`);
|
|
1229
2023
|
if (agent.localPath) out(` files: ${agent.localPath}`);
|
|
1230
2024
|
}
|
|
1231
2025
|
|
|
1232
|
-
async function
|
|
2026
|
+
async function callAgentlasMcpToolCli(name, args, { requireSession = false } = {}) {
|
|
1233
2027
|
if (typeof fetch !== "function") fail("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
|
|
1234
2028
|
const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
|
|
1235
2029
|
const headers = { "content-type": "application/json" };
|
|
1236
2030
|
const cookie = await cloudSessionCookieCli();
|
|
2031
|
+
if (requireSession && !cookie) fail("Agent Cloud에는 로그인이 필요합니다. 먼저 `agentlas login`을 실행하세요.");
|
|
1237
2032
|
if (cookie) headers.cookie = cookie;
|
|
1238
|
-
const resp = await
|
|
2033
|
+
const resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
|
|
1239
2034
|
method: "POST",
|
|
1240
2035
|
headers,
|
|
1241
|
-
body: JSON.stringify({ method:
|
|
2036
|
+
body: JSON.stringify({ method: name, params: { name, arguments: args || {} } }),
|
|
1242
2037
|
});
|
|
1243
|
-
if (!resp.ok) fail(
|
|
1244
|
-
const json =
|
|
1245
|
-
if (json.error) fail(
|
|
2038
|
+
if (!resp.ok) fail(`${name} 실패 ${resp.status}`);
|
|
2039
|
+
const json = parseHubJsonCli(resp, name);
|
|
2040
|
+
if (json.error) fail(`${name}: ${json.error.message || "unknown error"}`);
|
|
1246
2041
|
return json.result || null;
|
|
1247
2042
|
}
|
|
1248
2043
|
|
|
2044
|
+
async function fetchCloudManifestCli(slug) {
|
|
2045
|
+
return callAgentlasMcpToolCli("marketplace.get_manifest", { kind: "agent", slug });
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
async function listOwnedCloudAgentsCli(limit = 100) {
|
|
2049
|
+
const safeLimit = Math.max(1, Math.min(100, Number.isFinite(limit) ? Math.floor(limit) : 100));
|
|
2050
|
+
const result = (await callAgentlasMcpToolCli("cargo.search_agents", { q: "", limit: safeLimit }, { requireSession: true })) || {
|
|
2051
|
+
schema: "agentlas.agent_cloud.search.v1",
|
|
2052
|
+
source: "cloud",
|
|
2053
|
+
status: "ok",
|
|
2054
|
+
count: 0,
|
|
2055
|
+
total: 0,
|
|
2056
|
+
results: [],
|
|
2057
|
+
};
|
|
2058
|
+
if (!Array.isArray(result.results)) throw new Error("Agent Cloud list returned an invalid results contract.");
|
|
2059
|
+
if (result.results.length) {
|
|
2060
|
+
const state = readCloudAssetStateCli();
|
|
2061
|
+
for (const raw of result.results) {
|
|
2062
|
+
const descriptor = normalizeCloudAssetDescriptorCli(raw, "Agent Cloud list result");
|
|
2063
|
+
const key = cloudDescriptorKey(descriptor);
|
|
2064
|
+
const previous = state.assets[key];
|
|
2065
|
+
const preserveRoots = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
|
|
2066
|
+
state.assets[key] = { descriptor, sourceRoots: preserveRoots ? previous.sourceRoots : [] };
|
|
2067
|
+
}
|
|
2068
|
+
writeCloudAssetStateCli(state);
|
|
2069
|
+
}
|
|
2070
|
+
return result;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async function restoreOwnedCloudAgentCli(db, slug) {
|
|
2074
|
+
const raw = await callAgentlasMcpToolCli("cargo.restore_package", { slug }, { requireSession: true });
|
|
2075
|
+
if (!raw || raw.error) {
|
|
2076
|
+
const code = raw && raw.error ? raw.error : "agent_not_found";
|
|
2077
|
+
const message = raw && raw.message ? raw.message : `Agent Cloud package not found: ${slug}`;
|
|
2078
|
+
throw new Error(`${code}: ${message}`);
|
|
2079
|
+
}
|
|
2080
|
+
const restored = normalizeOwnerRestorePayloadCli(raw, slug);
|
|
2081
|
+
const cloudPackage = restored.cloudPackage;
|
|
2082
|
+
const listing = {
|
|
2083
|
+
slug: restored.slug || slug,
|
|
2084
|
+
name: restored.name || restored.nameEn || restored.slug || slug,
|
|
2085
|
+
nameEn: restored.nameEn || restored.name || restored.slug || slug,
|
|
2086
|
+
tagline: restored.tagline || restored.taglineEn || "",
|
|
2087
|
+
taglineEn: restored.taglineEn || restored.tagline || "",
|
|
2088
|
+
trustGrade: "A",
|
|
2089
|
+
visibility: "visible",
|
|
2090
|
+
source: "cloud",
|
|
2091
|
+
assetDescriptor: restored.descriptor,
|
|
2092
|
+
cloudPackage,
|
|
2093
|
+
};
|
|
2094
|
+
const agent = persistCloudListingCli(db, listing);
|
|
2095
|
+
let descriptor = restored.descriptor;
|
|
2096
|
+
let localStateWarning;
|
|
2097
|
+
try {
|
|
2098
|
+
descriptor = rememberCloudAssetDescriptorCli(restored.descriptor, { sourceRoot: agent.localPath || undefined });
|
|
2099
|
+
} catch (error) {
|
|
2100
|
+
localStateWarning = `Restore completed, but observed revision state could not be indexed: ${error.message || error}`;
|
|
2101
|
+
}
|
|
2102
|
+
return {
|
|
2103
|
+
schema: restored.schema || "agentlas.agent_cloud.restore.v1",
|
|
2104
|
+
source: "cloud",
|
|
2105
|
+
slug: agent.slug,
|
|
2106
|
+
name: agent.name,
|
|
2107
|
+
packageHash: cloudPackage.packageHash,
|
|
2108
|
+
packageHashVersion: cloudPackage.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
|
|
2109
|
+
cloudId: descriptor.cloudId,
|
|
2110
|
+
scope: descriptor.scope,
|
|
2111
|
+
revision: descriptor.revision,
|
|
2112
|
+
etag: descriptor.etag,
|
|
2113
|
+
updatedAt: descriptor.updatedAt,
|
|
2114
|
+
localPath: agent.localPath || null,
|
|
2115
|
+
...(localStateWarning ? { localStateWarning } : {}),
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function normalizeOwnerRestorePayloadCli(raw, expectedSlug) {
|
|
2120
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid_restore_contract");
|
|
2121
|
+
if (raw.schema !== "agentlas.agent_cloud.restore.v1" || raw.source !== "cloud" || raw.owner !== true) {
|
|
2122
|
+
throw new Error("invalid_restore_contract");
|
|
2123
|
+
}
|
|
2124
|
+
if (typeof raw.slug !== "string" || !raw.slug || raw.slug !== expectedSlug) {
|
|
2125
|
+
throw new Error(`restore_slug_mismatch: requested ${expectedSlug}; received ${String(raw.slug || "")}`);
|
|
2126
|
+
}
|
|
2127
|
+
const pkg = raw.cloudPackage;
|
|
2128
|
+
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg) || !Array.isArray(pkg.files)) {
|
|
2129
|
+
throw new Error("invalid_restore_contract");
|
|
2130
|
+
}
|
|
2131
|
+
const version = cloudPackageHashVersion(pkg.packageHashVersion);
|
|
2132
|
+
if (!version || !/^[a-f0-9]{64}$/i.test(String(pkg.packageHash || "").replace(/^sha256:/i, ""))) {
|
|
2133
|
+
throw new Error("invalid_restore_contract");
|
|
2134
|
+
}
|
|
2135
|
+
let descriptor;
|
|
2136
|
+
let nestedDescriptor;
|
|
2137
|
+
try {
|
|
2138
|
+
descriptor = normalizeCloudAssetDescriptorCli(raw, "owner restore receipt");
|
|
2139
|
+
nestedDescriptor = normalizeCloudAssetDescriptorCli({
|
|
2140
|
+
...pkg,
|
|
2141
|
+
slug: raw.slug,
|
|
2142
|
+
etag: raw.etag,
|
|
2143
|
+
}, "owner restore package receipt");
|
|
2144
|
+
} catch (error) {
|
|
2145
|
+
throw new Error(`invalid_restore_contract: ${error.message || error}`);
|
|
2146
|
+
}
|
|
2147
|
+
if (JSON.stringify(descriptor) !== JSON.stringify(nestedDescriptor)) {
|
|
2148
|
+
throw new Error("invalid_restore_contract: restore revision envelope and cloudPackage disagree");
|
|
2149
|
+
}
|
|
2150
|
+
if (!["agent", "team", "repo"].includes(pkg.agentKind) || !Number.isSafeInteger(pkg.fileCount) || !Number.isSafeInteger(pkg.totalBytes)) {
|
|
2151
|
+
throw new Error("invalid_restore_contract");
|
|
2152
|
+
}
|
|
2153
|
+
for (const file of pkg.files) {
|
|
2154
|
+
if (!file || typeof file !== "object" || typeof file.path !== "string" || !Number.isSafeInteger(file.bytes) || typeof file.sha256 !== "string" || typeof file.contentBase64 !== "string") {
|
|
2155
|
+
throw new Error("invalid_restore_contract");
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
const outerVersion = raw.packageHashVersion == null ? version : cloudPackageHashVersion(raw.packageHashVersion);
|
|
2159
|
+
if (
|
|
2160
|
+
(raw.packageHash != null && String(raw.packageHash) !== String(pkg.packageHash)) ||
|
|
2161
|
+
!outerVersion || outerVersion !== version ||
|
|
2162
|
+
(raw.fileCount != null && raw.fileCount !== pkg.fileCount) ||
|
|
2163
|
+
(raw.totalBytes != null && raw.totalBytes !== pkg.totalBytes) ||
|
|
2164
|
+
(raw.agentKind != null && raw.agentKind !== pkg.agentKind)
|
|
2165
|
+
) {
|
|
2166
|
+
throw new Error("invalid_restore_contract: restore envelope and cloudPackage disagree");
|
|
2167
|
+
}
|
|
2168
|
+
return {
|
|
2169
|
+
schema: raw.schema,
|
|
2170
|
+
source: raw.source,
|
|
2171
|
+
owner: true,
|
|
2172
|
+
slug: raw.slug,
|
|
2173
|
+
name: typeof raw.name === "string" && raw.name ? raw.name : raw.slug,
|
|
2174
|
+
nameEn: typeof raw.nameEn === "string" && raw.nameEn ? raw.nameEn : (raw.name || raw.slug),
|
|
2175
|
+
tagline: typeof raw.tagline === "string" ? raw.tagline : "",
|
|
2176
|
+
taglineEn: typeof raw.taglineEn === "string" ? raw.taglineEn : (raw.tagline || ""),
|
|
2177
|
+
descriptor,
|
|
2178
|
+
cloudPackage: {
|
|
2179
|
+
cloudId: descriptor.cloudId,
|
|
2180
|
+
scope: descriptor.scope,
|
|
2181
|
+
revision: descriptor.revision,
|
|
2182
|
+
updatedAt: descriptor.updatedAt,
|
|
2183
|
+
packageHash: String(pkg.packageHash).replace(/^sha256:/i, "").toLowerCase(),
|
|
2184
|
+
packageHashVersion: version,
|
|
2185
|
+
fileCount: pkg.fileCount,
|
|
2186
|
+
totalBytes: pkg.totalBytes,
|
|
2187
|
+
agentKind: pkg.agentKind,
|
|
2188
|
+
runtimeLabels: Array.isArray(pkg.runtimeLabels) ? pkg.runtimeLabels.filter((item) => typeof item === "string" && item.trim()) : [],
|
|
2189
|
+
files: pkg.files,
|
|
2190
|
+
},
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function cloudSystemPromptFromPackageCli(listing, slug) {
|
|
2195
|
+
const pkg = listing && listing.cloudPackage;
|
|
2196
|
+
if (!pkg || !Array.isArray(pkg.files) || !pkg.files.length) return "";
|
|
2197
|
+
const byPath = new Map();
|
|
2198
|
+
for (const file of pkg.files) {
|
|
2199
|
+
if (!file || typeof file.path !== "string" || typeof file.contentBase64 !== "string") continue;
|
|
2200
|
+
byPath.set(cloudPortablePathKey(file.path), file);
|
|
2201
|
+
}
|
|
2202
|
+
const readText = (candidate) => {
|
|
2203
|
+
const safe = cloudPortableRelativePath(candidate);
|
|
2204
|
+
if (!safe) return "";
|
|
2205
|
+
const file = byPath.get(cloudPortablePathKey(safe));
|
|
2206
|
+
if (!file) return "";
|
|
2207
|
+
let bytes;
|
|
2208
|
+
try { bytes = Buffer.from(file.contentBase64, "base64"); } catch { return ""; }
|
|
2209
|
+
if (!bytes.length || bytes.includes(0)) return "";
|
|
2210
|
+
const text = bytes.toString("utf8");
|
|
2211
|
+
if (!text.trim() || text.includes("\ufffd")) return "";
|
|
2212
|
+
return text.slice(0, 64 * 1024);
|
|
2213
|
+
};
|
|
2214
|
+
let manifest = null;
|
|
2215
|
+
const manifestFile = byPath.get(cloudPortablePathKey("agentlas.json"));
|
|
2216
|
+
if (manifestFile) {
|
|
2217
|
+
try { manifest = JSON.parse(Buffer.from(manifestFile.contentBase64, "base64").toString("utf8")); }
|
|
2218
|
+
catch { manifest = null; }
|
|
2219
|
+
}
|
|
2220
|
+
const declaredEntry = manifest && typeof manifest === "object" && typeof manifest.entry === "string"
|
|
2221
|
+
? cloudPortableRelativePath(manifest.entry)
|
|
2222
|
+
: null;
|
|
2223
|
+
const candidates = [
|
|
2224
|
+
declaredEntry,
|
|
2225
|
+
"AGENTS.md",
|
|
2226
|
+
"CLAUDE.md",
|
|
2227
|
+
"GEMINI.md",
|
|
2228
|
+
"AGENT.md",
|
|
2229
|
+
"agent.md",
|
|
2230
|
+
"system-prompt.md",
|
|
2231
|
+
"README.md",
|
|
2232
|
+
].filter(Boolean);
|
|
2233
|
+
let entryPath = "";
|
|
2234
|
+
let entryText = "";
|
|
2235
|
+
for (const candidate of candidates) {
|
|
2236
|
+
const text = readText(candidate);
|
|
2237
|
+
if (!text) continue;
|
|
2238
|
+
entryPath = candidate;
|
|
2239
|
+
entryText = text;
|
|
2240
|
+
break;
|
|
2241
|
+
}
|
|
2242
|
+
if (!entryText) return "";
|
|
2243
|
+
const installRoot = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
2244
|
+
return [
|
|
2245
|
+
`You are the Agentlas Cloud agent "${listing.name || slug}".`,
|
|
2246
|
+
`IMMUTABLE CLOUD AGENT ROOT: ${installRoot}`,
|
|
2247
|
+
`CANONICAL ENTRY: ${entryPath}`,
|
|
2248
|
+
`PACKAGE HASH: ${String(pkg.packageHash || "").replace(/^sha256:/i, "")}`,
|
|
2249
|
+
"Resolve package-relative references under IMMUTABLE CLOUD AGENT ROOT. Treat that root as read-only and do work in the user's active project.",
|
|
2250
|
+
"",
|
|
2251
|
+
"--- CLOUD AGENT ENTRY ---",
|
|
2252
|
+
entryText,
|
|
2253
|
+
].join("\n");
|
|
2254
|
+
}
|
|
2255
|
+
|
|
1249
2256
|
function persistCloudListingCli(db, listing) {
|
|
2257
|
+
if (listing?.delivery?.mode === "call_only") {
|
|
2258
|
+
throw new Error(`call-only Hub asset cannot be source-installed; invoke it with agentlas call ${listing.slug || "<slug>"}`);
|
|
2259
|
+
}
|
|
1250
2260
|
const slug = cloudSlug(listing.slug || listing.name || "cloud-agent");
|
|
2261
|
+
recoverCloudInstallJournalCli(db, slug);
|
|
1251
2262
|
const existing = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
|
|
1252
2263
|
const now = new Date().toISOString();
|
|
1253
2264
|
const envReqs = JSON.stringify(listing.envRequirements || []);
|
|
1254
2265
|
const mcpServers = JSON.stringify(listing.mcpServers || []);
|
|
1255
|
-
|
|
1256
|
-
db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, visibility=? WHERE slug=?")
|
|
1257
|
-
.run(listing.name || slug, listing.nameEn || listing.name || slug, listing.tagline || "", listing.taglineEn || listing.tagline || "", listing.systemPrompt || "", mcpServers, envReqs, listing.trustGrade || "unknown", listing.visibility || "visible", slug);
|
|
1258
|
-
const localPath = materializeCloudListingCli(existing.id, slug, listing);
|
|
1259
|
-
return { ...existing, slug, name: listing.name || slug, ...(localPath ? { localPath } : {}) };
|
|
1260
|
-
}
|
|
1261
|
-
const id = crypto.randomUUID();
|
|
2266
|
+
const id = existing?.id || crypto.randomUUID();
|
|
1262
2267
|
const hasVisibility = columnExists(db, "installed_agents", "visibility");
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
|
|
2268
|
+
let installedAt = now;
|
|
2269
|
+
if (existing && String(existing.installed_at || "") === installedAt) {
|
|
2270
|
+
installedAt = new Date(Date.now() + 1).toISOString();
|
|
2271
|
+
}
|
|
2272
|
+
const tone = listing.tone || "blue";
|
|
2273
|
+
const packageSystemPrompt = cloudSystemPromptFromPackageCli(listing, slug);
|
|
2274
|
+
const dbExpected = {
|
|
2275
|
+
id,
|
|
2276
|
+
slug,
|
|
2277
|
+
name: listing.name || slug,
|
|
2278
|
+
name_en: listing.nameEn || listing.name || slug,
|
|
2279
|
+
tagline: listing.tagline || "",
|
|
2280
|
+
tagline_en: listing.taglineEn || listing.tagline || "",
|
|
2281
|
+
system_prompt: packageSystemPrompt || listing.systemPrompt || "",
|
|
2282
|
+
mcp_servers_json: mcpServers,
|
|
2283
|
+
env_requirements_json: envReqs,
|
|
2284
|
+
trust_grade: listing.trustGrade || "unknown",
|
|
2285
|
+
installed_at: installedAt,
|
|
2286
|
+
tone,
|
|
2287
|
+
...(!existing ? { preferred_backend: null } : {}),
|
|
2288
|
+
...(hasVisibility ? { visibility: listing.visibility || "visible" } : {}),
|
|
2289
|
+
};
|
|
2290
|
+
const restore = materializeCloudListingCli(id, slug, listing, { deferCommit: true, dbExpected });
|
|
2291
|
+
const mutate = () => {
|
|
2292
|
+
if (existing) {
|
|
2293
|
+
if (hasVisibility) {
|
|
2294
|
+
db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=?, visibility=? WHERE slug=?")
|
|
2295
|
+
.run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility, slug);
|
|
2296
|
+
} else {
|
|
2297
|
+
db.prepare("UPDATE installed_agents SET name=?, name_en=?, tagline=?, tagline_en=?, system_prompt=?, mcp_servers_json=?, env_requirements_json=?, trust_grade=?, installed_at=?, tone=? WHERE slug=?")
|
|
2298
|
+
.run(dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, slug);
|
|
2299
|
+
}
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (hasVisibility) {
|
|
2303
|
+
db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone, visibility) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?,?)")
|
|
2304
|
+
.run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone, dbExpected.visibility);
|
|
2305
|
+
} else {
|
|
2306
|
+
db.prepare("INSERT INTO installed_agents (id, slug, name, name_en, tagline, tagline_en, system_prompt, mcp_servers_json, env_requirements_json, preferred_backend, trust_grade, installed_at, tone) VALUES (?,?,?,?,?,?,?,?,?,NULL,?,?,?)")
|
|
2307
|
+
.run(id, slug, dbExpected.name, dbExpected.name_en, dbExpected.tagline, dbExpected.tagline_en, dbExpected.system_prompt, mcpServers, envReqs, dbExpected.trust_grade, installedAt, tone);
|
|
2308
|
+
}
|
|
2309
|
+
};
|
|
2310
|
+
let dbCommitted = false;
|
|
2311
|
+
try {
|
|
2312
|
+
if (typeof db.transaction === "function") db.transaction(mutate)();
|
|
2313
|
+
else mutate();
|
|
2314
|
+
dbCommitted = true;
|
|
2315
|
+
restore?.commit();
|
|
2316
|
+
} catch (error) {
|
|
2317
|
+
if (!dbCommitted) restore?.rollback();
|
|
2318
|
+
throw error;
|
|
1269
2319
|
}
|
|
1270
|
-
const localPath =
|
|
1271
|
-
return
|
|
2320
|
+
const localPath = restore?.path || null;
|
|
2321
|
+
return existing
|
|
2322
|
+
? { ...existing, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) }
|
|
2323
|
+
: { id, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) };
|
|
1272
2324
|
}
|
|
1273
2325
|
|
|
1274
|
-
function materializeCloudListingCli(agentId, slug, listing) {
|
|
2326
|
+
function materializeCloudListingCli(agentId, slug, listing, options = {}) {
|
|
1275
2327
|
const pkg = listing.cloudPackage;
|
|
1276
2328
|
if (!pkg || !Array.isArray(pkg.files) || pkg.files.length === 0) return null;
|
|
2329
|
+
if (pkg.files.length > CLOUD_MAX_FILES) throw new Error(`cloud package exceeds ${CLOUD_MAX_FILES} files`);
|
|
2330
|
+
if (!Number.isSafeInteger(pkg.fileCount) || pkg.fileCount !== pkg.files.length) {
|
|
2331
|
+
throw new Error("cloud package file count does not match its manifest");
|
|
2332
|
+
}
|
|
2333
|
+
if (!Number.isSafeInteger(pkg.totalBytes) || pkg.totalBytes < 0 || pkg.totalBytes > CLOUD_MAX_TOTAL_BYTES) {
|
|
2334
|
+
throw new Error("cloud package total byte count is invalid");
|
|
2335
|
+
}
|
|
2336
|
+
const packageHashVersion = cloudPackageHashVersion(pkg.packageHashVersion);
|
|
2337
|
+
if (!packageHashVersion) throw new Error(`unsupported cloud package hash version: ${pkg.packageHashVersion}`);
|
|
2338
|
+
const assetDescriptor = listing.assetDescriptor
|
|
2339
|
+
? normalizeCloudAssetDescriptorCli(listing.assetDescriptor, "restore asset descriptor")
|
|
2340
|
+
: null;
|
|
2341
|
+
if (assetDescriptor && assetDescriptor.slug !== slug) throw new Error("restore asset descriptor slug mismatch");
|
|
2342
|
+
const pathConflict = cloudPortablePathConflict(pkg.files.map((file) => file && file.path));
|
|
2343
|
+
if (pathConflict) throw new Error(pathConflict.message);
|
|
1277
2344
|
const dir = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
2345
|
+
const parent = path.dirname(dir);
|
|
2346
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
2347
|
+
const nonce = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
2348
|
+
const staging = path.join(parent, `.${path.basename(dir)}.installing-${nonce}`);
|
|
2349
|
+
const backup = path.join(parent, `.${path.basename(dir)}.backup-${nonce}`);
|
|
2350
|
+
const journal = path.join(parent, `.${path.basename(dir)}.install-journal.json`);
|
|
2351
|
+
const seen = new Set();
|
|
2352
|
+
const verifiedFiles = [];
|
|
2353
|
+
let verifiedTotalBytes = 0;
|
|
2354
|
+
let movedExisting = false;
|
|
2355
|
+
let installed = false;
|
|
1281
2356
|
try {
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
2357
|
+
fs.mkdirSync(staging, { recursive: false, mode: 0o700 });
|
|
2358
|
+
cloudApplyPrivateDirectoryMode(staging);
|
|
2359
|
+
for (const file of pkg.files) {
|
|
2360
|
+
const target = resolveCloudInstallPathCli(staging, file.path);
|
|
2361
|
+
const normalizedPath = path.relative(staging, target).split(path.sep).join("/");
|
|
2362
|
+
if (seen.has(normalizedPath)) throw new Error(`duplicate cloud package path: ${file.path}`);
|
|
2363
|
+
seen.add(normalizedPath);
|
|
2364
|
+
if (packageHashVersion === CLOUD_PACKAGE_HASH_V2 && typeof file.executable !== "boolean") {
|
|
2365
|
+
throw new Error(`cloud package hash v2 requires executable boolean: ${file.path}`);
|
|
2366
|
+
}
|
|
2367
|
+
if (packageHashVersion === CLOUD_PACKAGE_HASH_V1 && file.executable !== undefined) {
|
|
2368
|
+
throw new Error(`legacy cloud package hash v1 cannot authenticate executable flag: ${file.path}`);
|
|
2369
|
+
}
|
|
2370
|
+
if (!cloudCanonicalBase64(file.contentBase64)) {
|
|
2371
|
+
throw new Error(`cloud package file base64 is not canonical: ${file.path}`);
|
|
2372
|
+
}
|
|
2373
|
+
const bytes = Buffer.from(String(file.contentBase64 || ""), "base64");
|
|
2374
|
+
if (!Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > CLOUD_MAX_FILE_BYTES) {
|
|
2375
|
+
throw new Error(`cloud package file byte count is invalid: ${file.path}`);
|
|
2376
|
+
}
|
|
2377
|
+
if (bytes.length !== Number(file.bytes) || sha(bytes) !== String(file.sha256 || "").toLowerCase()) {
|
|
2378
|
+
throw new Error(`cloud package file integrity failed: ${file.path}`);
|
|
2379
|
+
}
|
|
2380
|
+
verifiedFiles.push({
|
|
2381
|
+
path: normalizedPath,
|
|
2382
|
+
bytes: bytes.length,
|
|
2383
|
+
sha256: String(file.sha256 || "").toLowerCase(),
|
|
2384
|
+
...(packageHashVersion === CLOUD_PACKAGE_HASH_V2 ? { executable: file.executable } : {}),
|
|
2385
|
+
});
|
|
2386
|
+
verifiedTotalBytes += bytes.length;
|
|
2387
|
+
if (verifiedTotalBytes > CLOUD_MAX_TOTAL_BYTES) throw new Error("cloud package exceeds total byte limit");
|
|
2388
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
2389
|
+
cloudApplyPrivateDirectoryMode(path.dirname(target));
|
|
2390
|
+
const mode = packageHashVersion === CLOUD_PACKAGE_HASH_V2 && file.executable ? 0o700 : 0o600;
|
|
2391
|
+
fs.writeFileSync(target, bytes, { mode });
|
|
2392
|
+
cloudApplyPortableFileMode(target, mode);
|
|
2393
|
+
}
|
|
2394
|
+
const expectedPackageHash = String(pkg.packageHash || "").toLowerCase().replace(/^sha256:/, "");
|
|
2395
|
+
if (!/^[a-f0-9]{64}$/.test(expectedPackageHash)) {
|
|
2396
|
+
throw new Error("cloud package aggregate hash is missing or invalid");
|
|
2397
|
+
}
|
|
2398
|
+
const actualPackageHash = cloudHashPackage(verifiedFiles, packageHashVersion);
|
|
2399
|
+
if (actualPackageHash !== expectedPackageHash) {
|
|
2400
|
+
throw new Error("cloud package aggregate integrity failed");
|
|
2401
|
+
}
|
|
2402
|
+
if (assetDescriptor && (
|
|
2403
|
+
assetDescriptor.packageHash !== expectedPackageHash ||
|
|
2404
|
+
assetDescriptor.packageHashVersion !== packageHashVersion
|
|
2405
|
+
)) {
|
|
2406
|
+
throw new Error("restore asset descriptor package identity mismatch");
|
|
2407
|
+
}
|
|
2408
|
+
if (verifiedTotalBytes !== pkg.totalBytes) throw new Error("cloud package total byte count does not match its files");
|
|
2409
|
+
const restoredAt = new Date().toISOString();
|
|
2410
|
+
fs.writeFileSync(
|
|
2411
|
+
path.join(staging, ".agentlas-cloud-package.json"),
|
|
2412
|
+
JSON.stringify({
|
|
2413
|
+
schemaVersion: 1,
|
|
2414
|
+
source: "agentlas-cloud",
|
|
2415
|
+
slug,
|
|
2416
|
+
packageHash: expectedPackageHash,
|
|
2417
|
+
packageHashVersion,
|
|
2418
|
+
fileCount: verifiedFiles.length,
|
|
2419
|
+
totalBytes: verifiedTotalBytes,
|
|
2420
|
+
executablePaths: packageHashVersion === CLOUD_PACKAGE_HASH_V2
|
|
2421
|
+
? verifiedFiles.filter((file) => file.executable).map((file) => file.path).sort()
|
|
2422
|
+
: undefined,
|
|
2423
|
+
...(assetDescriptor ? {
|
|
2424
|
+
cloudId: assetDescriptor.cloudId,
|
|
2425
|
+
scope: assetDescriptor.scope,
|
|
2426
|
+
revision: assetDescriptor.revision,
|
|
2427
|
+
etag: assetDescriptor.etag,
|
|
2428
|
+
updatedAt: assetDescriptor.updatedAt,
|
|
2429
|
+
cloudAssets: { [assetDescriptor.scope]: assetDescriptor },
|
|
2430
|
+
} : {}),
|
|
2431
|
+
restoredAt,
|
|
2432
|
+
}, null, 2) + "\n",
|
|
2433
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
2434
|
+
);
|
|
2435
|
+
cloudApplyPortableFileMode(path.join(staging, CLOUD_RESTORE_MARKER_PATH), 0o600);
|
|
2436
|
+
cloudVerifyRestoredSnapshot(staging, verifiedFiles, {
|
|
2437
|
+
slug,
|
|
2438
|
+
packageHash: expectedPackageHash,
|
|
2439
|
+
packageHashVersion,
|
|
2440
|
+
totalBytes: verifiedTotalBytes,
|
|
2441
|
+
assetDescriptor,
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
if (options.deferCommit) {
|
|
2445
|
+
writeCloudInstallJournalCli(journal, {
|
|
2446
|
+
schemaVersion: 1,
|
|
2447
|
+
slug,
|
|
2448
|
+
phase: "prepared",
|
|
2449
|
+
destination: dir,
|
|
2450
|
+
staging,
|
|
2451
|
+
backup,
|
|
2452
|
+
hadExisting: fs.existsSync(dir),
|
|
2453
|
+
dbExpected: options.dbExpected || {},
|
|
2454
|
+
});
|
|
1290
2455
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
2456
|
+
|
|
2457
|
+
// A Cloud agent is an immutable asset snapshot. Replace the managed install
|
|
2458
|
+
// as a whole so removed files and local mutations cannot leak across versions.
|
|
2459
|
+
if (fs.existsSync(dir)) {
|
|
2460
|
+
fs.renameSync(dir, backup);
|
|
2461
|
+
movedExisting = true;
|
|
2462
|
+
}
|
|
2463
|
+
fs.renameSync(staging, dir);
|
|
2464
|
+
cloudFsyncDirectoryCli(parent);
|
|
2465
|
+
installed = true;
|
|
2466
|
+
if (options.deferCommit) {
|
|
2467
|
+
writeCloudInstallJournalCli(journal, {
|
|
2468
|
+
schemaVersion: 1,
|
|
2469
|
+
slug,
|
|
2470
|
+
phase: "disk-swapped-db-pending",
|
|
2471
|
+
destination: dir,
|
|
2472
|
+
staging,
|
|
2473
|
+
backup,
|
|
2474
|
+
hadExisting: movedExisting,
|
|
2475
|
+
dbExpected: options.dbExpected || {},
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
} catch (error) {
|
|
2479
|
+
rollbackCloudInstallSwapCli({ destination: dir, staging, backup, movedExisting, installed });
|
|
2480
|
+
try { if (fs.existsSync(journal)) fs.unlinkSync(journal); } catch { /* best-effort */ }
|
|
2481
|
+
throw error;
|
|
2482
|
+
} finally {
|
|
2483
|
+
try { if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
2484
|
+
try { if (!options.deferCommit && installed && fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
2485
|
+
}
|
|
2486
|
+
if (!options.deferCommit) return dir;
|
|
2487
|
+
let settled = false;
|
|
2488
|
+
return {
|
|
2489
|
+
path: dir,
|
|
2490
|
+
commit() {
|
|
2491
|
+
if (settled) return;
|
|
2492
|
+
writeCloudInstallJournalCli(journal, {
|
|
2493
|
+
schemaVersion: 1,
|
|
2494
|
+
slug,
|
|
2495
|
+
phase: "db-committed",
|
|
2496
|
+
destination: dir,
|
|
2497
|
+
staging,
|
|
2498
|
+
backup,
|
|
2499
|
+
hadExisting: movedExisting,
|
|
2500
|
+
dbExpected: options.dbExpected || {},
|
|
2501
|
+
});
|
|
2502
|
+
if (fs.existsSync(backup)) fs.rmSync(backup, { recursive: true, force: true });
|
|
2503
|
+
if (fs.existsSync(journal)) fs.unlinkSync(journal);
|
|
2504
|
+
cloudFsyncDirectoryCli(parent);
|
|
2505
|
+
settled = true;
|
|
2506
|
+
},
|
|
2507
|
+
rollback() {
|
|
2508
|
+
if (settled) return;
|
|
2509
|
+
rollbackCloudInstallSwapCli({ destination: dir, staging, backup, movedExisting, installed });
|
|
2510
|
+
if (fs.existsSync(journal)) fs.unlinkSync(journal);
|
|
2511
|
+
cloudFsyncDirectoryCli(parent);
|
|
2512
|
+
settled = true;
|
|
2513
|
+
},
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function writeCloudInstallJournalCli(journalPath, value) {
|
|
2518
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 0o700 });
|
|
2519
|
+
const temp = `${journalPath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
2520
|
+
const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
2521
|
+
try {
|
|
2522
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
2523
|
+
fs.fsyncSync(fd);
|
|
2524
|
+
} finally {
|
|
2525
|
+
fs.closeSync(fd);
|
|
1293
2526
|
}
|
|
1294
|
-
fs.
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
2527
|
+
fs.renameSync(temp, journalPath);
|
|
2528
|
+
cloudApplyPortableFileMode(journalPath, 0o600);
|
|
2529
|
+
cloudFsyncDirectoryCli(path.dirname(journalPath));
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function cloudFsyncDirectoryCli(directory) {
|
|
2533
|
+
if (process.platform === "win32") return;
|
|
2534
|
+
let fd;
|
|
2535
|
+
try {
|
|
2536
|
+
fd = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
2537
|
+
fs.fsyncSync(fd);
|
|
2538
|
+
} catch { /* some filesystems do not support directory fsync */ }
|
|
2539
|
+
finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ } }
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function rollbackCloudInstallSwapCli({ destination, staging, backup, movedExisting, installed }) {
|
|
2543
|
+
if (installed && fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
2544
|
+
if (movedExisting && fs.existsSync(backup) && !fs.existsSync(destination)) fs.renameSync(backup, destination);
|
|
2545
|
+
if (fs.existsSync(staging)) fs.rmSync(staging, { recursive: true, force: true });
|
|
2546
|
+
cloudFsyncDirectoryCli(path.dirname(destination));
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function recoverCloudInstallJournalCli(db, slug) {
|
|
2550
|
+
const destination = path.join(userDataDir(), "cloud-agent-installs", slug);
|
|
2551
|
+
const parent = path.dirname(destination);
|
|
2552
|
+
const journalPath = path.join(parent, `.${path.basename(destination)}.install-journal.json`);
|
|
2553
|
+
if (!fs.existsSync(journalPath)) return;
|
|
2554
|
+
let journal;
|
|
2555
|
+
try { journal = JSON.parse(fs.readFileSync(journalPath, "utf8")); } catch { throw new Error(`cloud install recovery journal is unreadable for ${slug}`); }
|
|
2556
|
+
const safeSibling = (candidate, prefix) =>
|
|
2557
|
+
typeof candidate === "string" && path.dirname(candidate) === parent && path.basename(candidate).startsWith(prefix);
|
|
2558
|
+
if (
|
|
2559
|
+
journal.schemaVersion !== 1 || journal.slug !== slug || journal.destination !== destination ||
|
|
2560
|
+
!["prepared", "disk-swapped-db-pending", "db-committed"].includes(journal.phase) ||
|
|
2561
|
+
typeof journal.hadExisting !== "boolean" ||
|
|
2562
|
+
!safeSibling(journal.staging, `.${path.basename(destination)}.installing-`) ||
|
|
2563
|
+
!safeSibling(journal.backup, `.${path.basename(destination)}.backup-`)
|
|
2564
|
+
) {
|
|
2565
|
+
throw new Error(`cloud install recovery journal is invalid for ${slug}`);
|
|
2566
|
+
}
|
|
2567
|
+
const row = db.prepare("SELECT * FROM installed_agents WHERE slug=?").get(slug);
|
|
2568
|
+
const expected = journal.dbExpected && typeof journal.dbExpected === "object" ? journal.dbExpected : {};
|
|
2569
|
+
const expectedEntries = Object.entries(expected);
|
|
2570
|
+
const dbMatches = Boolean(row) && expectedEntries.length > 0 && expectedEntries.every(
|
|
2571
|
+
([key, value]) => String(row[key] ?? "") === String(value ?? ""),
|
|
1298
2572
|
);
|
|
1299
|
-
|
|
2573
|
+
if (journal.phase === "prepared") {
|
|
2574
|
+
// The DB mutation starts only after materializeCloudListingCli returns, so a
|
|
2575
|
+
// prepared journal always represents the pre-DB state. Cover both rename
|
|
2576
|
+
// crash windows: old→backup and staging→destination.
|
|
2577
|
+
if (journal.hadExisting) {
|
|
2578
|
+
if (fs.existsSync(journal.backup)) {
|
|
2579
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
2580
|
+
fs.renameSync(journal.backup, destination);
|
|
2581
|
+
} else if (!fs.existsSync(destination)) {
|
|
2582
|
+
throw new Error(`prepared cloud install lost both destination and backup for ${slug}`);
|
|
2583
|
+
}
|
|
2584
|
+
} else {
|
|
2585
|
+
if (fs.existsSync(journal.backup)) {
|
|
2586
|
+
throw new Error(`prepared first cloud install has an unexpected backup for ${slug}`);
|
|
2587
|
+
}
|
|
2588
|
+
if (fs.existsSync(destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
2589
|
+
}
|
|
2590
|
+
if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
|
|
2591
|
+
} else if (journal.phase === "db-committed" || dbMatches) {
|
|
2592
|
+
if (!fs.existsSync(destination) && fs.existsSync(journal.staging)) fs.renameSync(journal.staging, destination);
|
|
2593
|
+
if (!fs.existsSync(destination)) throw new Error(`committed cloud install is missing for ${slug}`);
|
|
2594
|
+
if (fs.existsSync(journal.backup)) fs.rmSync(journal.backup, { recursive: true, force: true });
|
|
2595
|
+
if (fs.existsSync(journal.staging)) fs.rmSync(journal.staging, { recursive: true, force: true });
|
|
2596
|
+
} else if (journal.phase === "disk-swapped-db-pending") {
|
|
2597
|
+
if (!fs.existsSync(destination)) throw new Error(`pending cloud install destination is missing for ${slug}`);
|
|
2598
|
+
if (journal.hadExisting !== fs.existsSync(journal.backup)) {
|
|
2599
|
+
throw new Error(`pending cloud install backup state is invalid for ${slug}`);
|
|
2600
|
+
}
|
|
2601
|
+
rollbackCloudInstallSwapCli({
|
|
2602
|
+
destination,
|
|
2603
|
+
staging: journal.staging,
|
|
2604
|
+
backup: journal.backup,
|
|
2605
|
+
movedExisting: Boolean(journal.hadExisting),
|
|
2606
|
+
installed: true,
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
fs.unlinkSync(journalPath);
|
|
2610
|
+
cloudFsyncDirectoryCli(parent);
|
|
1300
2611
|
}
|
|
1301
2612
|
|
|
1302
|
-
function
|
|
1303
|
-
const
|
|
1304
|
-
if (!
|
|
1305
|
-
|
|
2613
|
+
function recoverCloudInstallJournalsCli(db) {
|
|
2614
|
+
const parent = path.join(userDataDir(), "cloud-agent-installs");
|
|
2615
|
+
if (!fs.existsSync(parent)) return 0;
|
|
2616
|
+
let recovered = 0;
|
|
2617
|
+
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
2618
|
+
if (!entry.name.endsWith(".install-journal.json")) continue;
|
|
2619
|
+
const match = entry.name.match(/^\.([a-z0-9][a-z0-9-]{0,63})\.install-journal\.json$/);
|
|
2620
|
+
if (!match || cloudSlug(match[1]) !== match[1] || !entry.isFile() || entry.isSymbolicLink()) {
|
|
2621
|
+
throw new Error(`invalid cloud install recovery journal entry: ${entry.name}`);
|
|
2622
|
+
}
|
|
2623
|
+
recoverCloudInstallJournalCli(db, match[1]);
|
|
2624
|
+
recovered += 1;
|
|
1306
2625
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
2626
|
+
return recovered;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
function resolveCloudInstallPathCli(root, relPath) {
|
|
2630
|
+
const normalized = cloudPortableRelativePath(relPath);
|
|
2631
|
+
if (!normalized || cloudPortablePathKey(normalized) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
|
|
2632
|
+
throw new Error(`unsafe cloud package path: ${relPath}`);
|
|
1310
2633
|
}
|
|
2634
|
+
const parts = normalized.split("/");
|
|
1311
2635
|
const target = path.resolve(root, ...parts);
|
|
1312
2636
|
const relative = path.relative(root, target);
|
|
1313
2637
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
1314
|
-
|
|
2638
|
+
throw new Error(`cloud package path escapes install folder: ${relPath}`);
|
|
1315
2639
|
}
|
|
1316
2640
|
return target;
|
|
1317
2641
|
}
|
|
1318
2642
|
|
|
1319
2643
|
function printCloudPackageResult(result) {
|
|
1320
2644
|
out(`${result.status === "blocked" ? "✖" : "✓"} ${result.summary}`);
|
|
2645
|
+
out(` target: ${result.manifest.visibility === "marketplace" ? "Agentlas Hub (public)" : "Agent Cloud (owner-private)"}`);
|
|
1321
2646
|
out(` slug: ${result.manifest.slug}`);
|
|
1322
2647
|
out(` files: ${result.manifest.includedFileCount}/${result.manifest.fileCount}`);
|
|
1323
2648
|
out(` hash: ${result.manifest.packageHash}`);
|
|
@@ -1328,11 +2653,106 @@ function printCloudPackageResult(result) {
|
|
|
1328
2653
|
out(" findings:");
|
|
1329
2654
|
for (const f of findings.slice(0, 20)) out(` - ${f.severity} ${f.file ? f.file + ": " : ""}${f.message}`);
|
|
1330
2655
|
}
|
|
1331
|
-
if (result.registration)
|
|
2656
|
+
if (result.registration) {
|
|
2657
|
+
const label = result.manifest.visibility === "marketplace" ? "hub" : "cloud";
|
|
2658
|
+
out(` ${label}: ${result.registration.marketplaceUrl || result.registration.url || result.registration.cloudId}`);
|
|
2659
|
+
if (result.registration.localStateWarning) out(` warning: ${result.registration.localStateWarning}`);
|
|
2660
|
+
}
|
|
1332
2661
|
}
|
|
1333
2662
|
|
|
1334
|
-
function
|
|
1335
|
-
|
|
2663
|
+
function cloudPackageSnapshot(files) {
|
|
2664
|
+
return new Map(files.map((file) => [file.path, file]));
|
|
2665
|
+
}
|
|
2666
|
+
function cloudReadPublicCareerCard(snapshot, findings) {
|
|
2667
|
+
const relativePath = ".agentlas/public-career-card.json";
|
|
2668
|
+
const file = snapshot.get(relativePath);
|
|
2669
|
+
if (!file) return undefined;
|
|
2670
|
+
let parsed;
|
|
2671
|
+
try { parsed = JSON.parse(Buffer.from(file.contentBase64, "base64").toString("utf8")); }
|
|
2672
|
+
catch {
|
|
2673
|
+
findings.push(cloudCareerFinding("career-card-invalid-json", "structure", "Career Graph public card is not valid JSON."));
|
|
2674
|
+
return undefined;
|
|
2675
|
+
}
|
|
2676
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.kind !== "agentlas-public-career-card") {
|
|
2677
|
+
findings.push(cloudCareerFinding("career-card-invalid-kind", "structure", "Career Graph public card has an invalid kind."));
|
|
2678
|
+
return undefined;
|
|
2679
|
+
}
|
|
2680
|
+
const privacy = parsed.privacy && typeof parsed.privacy === "object" && !Array.isArray(parsed.privacy) ? parsed.privacy : {};
|
|
2681
|
+
for (const key of ["rawLocalPathsIncluded", "rawPromptsIncluded", "rawTranscriptsIncluded", "sourceTextIncluded"]) {
|
|
2682
|
+
if (privacy[key] !== false) findings.push(cloudCareerFinding(`career-card-privacy-${key}`, "policy", `Career Graph public card must set privacy.${key}=false.`));
|
|
2683
|
+
}
|
|
2684
|
+
if (cloudContainsAbsoluteLocalPath(JSON.stringify(parsed))) {
|
|
2685
|
+
findings.push(cloudCareerFinding("career-card-local-path", "policy", "Career Graph public card contains a local absolute path."));
|
|
2686
|
+
}
|
|
2687
|
+
if (findings.some((finding) => finding.severity === "blocker" && finding.id.startsWith("career-card-"))) return undefined;
|
|
2688
|
+
return cloudSanitizePublicCareerCard(parsed);
|
|
2689
|
+
}
|
|
2690
|
+
function cloudCareerFinding(id, category, message) {
|
|
2691
|
+
return {
|
|
2692
|
+
id,
|
|
2693
|
+
severity: "blocker",
|
|
2694
|
+
category,
|
|
2695
|
+
file: ".agentlas/public-career-card.json",
|
|
2696
|
+
message,
|
|
2697
|
+
remediation: "Regenerate a redacted aggregate-only public Career Graph card before publishing.",
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function cloudContainsAbsoluteLocalPath(value) {
|
|
2701
|
+
return (
|
|
2702
|
+
(os.homedir() && value.includes(os.homedir())) ||
|
|
2703
|
+
/(?:^|["'\s:(])\/(?:Users|home|var|tmp|private|Volumes|opt|etc)\//i.test(value) ||
|
|
2704
|
+
/(?:^|["'\s:(])[A-Za-z]:[\\/]/.test(value) ||
|
|
2705
|
+
/(?:^|["'\s:(])\\\\[^\\\s]+\\/.test(value)
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
function cloudSanitizePublicCareerCard(parsed) {
|
|
2709
|
+
const card = { kind: "agentlas-public-career-card" };
|
|
2710
|
+
for (const [key, max] of [["schemaVersion", 80], ["generatedAt", 80], ["projectName", 200], ["indexStatus", 80], ["policy", 160]]) {
|
|
2711
|
+
if (typeof parsed[key] === "string" && parsed[key].length <= max) card[key] = parsed[key];
|
|
2712
|
+
}
|
|
2713
|
+
card.privacy = {
|
|
2714
|
+
rawLocalPathsIncluded: false,
|
|
2715
|
+
rawPromptsIncluded: false,
|
|
2716
|
+
rawTranscriptsIncluded: false,
|
|
2717
|
+
sourceTextIncluded: false,
|
|
2718
|
+
};
|
|
2719
|
+
for (const key of ["counts", "sourceKinds", "nodeTypes", "edgeTypes"]) {
|
|
2720
|
+
const safe = cloudSanitizeCountRecord(parsed[key]);
|
|
2721
|
+
if (safe) card[key] = safe;
|
|
2722
|
+
}
|
|
2723
|
+
for (const key of ["canonicalSources", "staleSourceCount"]) {
|
|
2724
|
+
if (Number.isSafeInteger(parsed[key]) && parsed[key] >= 0) card[key] = parsed[key];
|
|
2725
|
+
}
|
|
2726
|
+
return card;
|
|
2727
|
+
}
|
|
2728
|
+
function cloudSanitizeCountRecord(value) {
|
|
2729
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
2730
|
+
const result = {};
|
|
2731
|
+
for (const [key, count] of Object.entries(value).slice(0, 200)) {
|
|
2732
|
+
if (/^[A-Za-z0-9_.:-]{1,80}$/.test(key) && Number.isSafeInteger(count) && count >= 0) result[key] = count;
|
|
2733
|
+
}
|
|
2734
|
+
return Object.keys(result).length ? result : undefined;
|
|
2735
|
+
}
|
|
2736
|
+
function cloudReplacePublicCareerCard(scan, card) {
|
|
2737
|
+
const relativePath = ".agentlas/public-career-card.json";
|
|
2738
|
+
const includedIndex = scan.included.findIndex((file) => file.path === relativePath);
|
|
2739
|
+
const existing = includedIndex >= 0 ? scan.included[includedIndex] : null;
|
|
2740
|
+
if (includedIndex >= 0) scan.included.splice(includedIndex, 1);
|
|
2741
|
+
const fileRecord = scan.files.find((file) => file.path === relativePath);
|
|
2742
|
+
if (!card) {
|
|
2743
|
+
if (fileRecord) { fileRecord.included = false; fileRecord.reason = "public-career-card-blocked"; }
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
const bytes = Buffer.from(JSON.stringify(card, null, 2) + "\n", "utf8");
|
|
2747
|
+
const replacement = { path: relativePath, bytes: bytes.length, sha256: sha(bytes), contentBase64: bytes.toString("base64"), executable: false };
|
|
2748
|
+
scan.included.push(replacement);
|
|
2749
|
+
scan.included.sort(cloudCodePointPathOrder);
|
|
2750
|
+
scan.totalBytes += bytes.length - (existing?.bytes || 0);
|
|
2751
|
+
if (fileRecord) Object.assign(fileRecord, { bytes: bytes.length, sha256: replacement.sha256, kind: "text", executable: false, included: true, reason: undefined });
|
|
2752
|
+
else scan.files.push({ path: relativePath, bytes: bytes.length, sha256: replacement.sha256, kind: "text", executable: false, included: true });
|
|
2753
|
+
}
|
|
2754
|
+
function cloudReadName(snapshot, fallbackName) {
|
|
2755
|
+
const manifest = cloudReadPackageJson(snapshot);
|
|
1336
2756
|
const explicit = stringFirstCli(
|
|
1337
2757
|
manifest.agentlas?.displayName,
|
|
1338
2758
|
manifest.agentlas?.name,
|
|
@@ -1341,12 +2761,12 @@ function cloudReadName(rootPath) {
|
|
|
1341
2761
|
manifest.routingCard?.name,
|
|
1342
2762
|
);
|
|
1343
2763
|
if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1344
|
-
const text = cloudReadFirst(
|
|
2764
|
+
const text = cloudReadFirst(snapshot, ["agent.md", "AGENT.md", "README.md", "CLAUDE.md", "AGENTS.md"], 2000);
|
|
1345
2765
|
const heading = text.match(/^#\s+(.+)$/m);
|
|
1346
|
-
return (heading ? heading[1] :
|
|
2766
|
+
return (heading ? heading[1] : fallbackName).replace(/\s+/g, " ").trim().slice(0, 80);
|
|
1347
2767
|
}
|
|
1348
|
-
function cloudReadTagline(
|
|
1349
|
-
const manifest = cloudReadPackageJson(
|
|
2768
|
+
function cloudReadTagline(snapshot) {
|
|
2769
|
+
const manifest = cloudReadPackageJson(snapshot);
|
|
1350
2770
|
const explicit = stringFirstCli(
|
|
1351
2771
|
manifest.agentlas?.summary,
|
|
1352
2772
|
manifest.agentlas?.description,
|
|
@@ -1355,15 +2775,15 @@ function cloudReadTagline(rootPath) {
|
|
|
1355
2775
|
manifest.routingCard?.summary,
|
|
1356
2776
|
);
|
|
1357
2777
|
if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
1358
|
-
const text = cloudReadFirst(
|
|
2778
|
+
const text = cloudReadFirst(snapshot, ["README.md", "agent.md", "AGENT.md"], 3000);
|
|
1359
2779
|
for (const line of text.split(/\r?\n/)) {
|
|
1360
2780
|
const t = line.trim();
|
|
1361
2781
|
if (t && !t.startsWith("#") && !t.startsWith(">")) return t.slice(0, 160);
|
|
1362
2782
|
}
|
|
1363
2783
|
return "Portable Agentlas cloud agent package.";
|
|
1364
2784
|
}
|
|
1365
|
-
function cloudReadStableSlug(
|
|
1366
|
-
const manifest = cloudReadPackageJson(
|
|
2785
|
+
function cloudReadStableSlug(snapshot) {
|
|
2786
|
+
const manifest = cloudReadPackageJson(snapshot);
|
|
1367
2787
|
return stringFirstCli(
|
|
1368
2788
|
manifest.agentlas?.slug,
|
|
1369
2789
|
manifest.agentlas?.id,
|
|
@@ -1374,52 +2794,321 @@ function cloudReadStableSlug(rootPath) {
|
|
|
1374
2794
|
manifest.routingCard?.agent_card_ref?.slug,
|
|
1375
2795
|
);
|
|
1376
2796
|
}
|
|
1377
|
-
function cloudReadPackageJson(
|
|
2797
|
+
function cloudReadPackageJson(snapshot) {
|
|
1378
2798
|
return {
|
|
1379
|
-
agentlas:
|
|
1380
|
-
manifest:
|
|
1381
|
-
agentCard:
|
|
1382
|
-
routingCard:
|
|
2799
|
+
agentlas: cloudReadSnapshotJson(snapshot, "agentlas.json"),
|
|
2800
|
+
manifest: cloudReadSnapshotJson(snapshot, "manifest.json"),
|
|
2801
|
+
agentCard: cloudReadSnapshotJson(snapshot, ".agentlas/agent-card.json"),
|
|
2802
|
+
routingCard: cloudReadSnapshotJson(snapshot, ".agentlas/routing-card.json"),
|
|
1383
2803
|
};
|
|
1384
2804
|
}
|
|
1385
|
-
function
|
|
1386
|
-
|
|
1387
|
-
|
|
2805
|
+
function cloudReadSnapshotJson(snapshot, relativePath) {
|
|
2806
|
+
try {
|
|
2807
|
+
const parsed = JSON.parse(cloudReadSnapshotText(snapshot, relativePath));
|
|
2808
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2809
|
+
} catch { return {}; }
|
|
2810
|
+
}
|
|
2811
|
+
function stringFirstCli(...values) {
|
|
2812
|
+
for (const value of values) {
|
|
2813
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
2814
|
+
}
|
|
2815
|
+
return "";
|
|
2816
|
+
}
|
|
2817
|
+
function cloudReadFirst(snapshot, names, maxChars) {
|
|
2818
|
+
for (const name of names) {
|
|
2819
|
+
const text = cloudReadSnapshotText(snapshot, name);
|
|
2820
|
+
if (text) return text.slice(0, maxChars);
|
|
2821
|
+
}
|
|
2822
|
+
return "";
|
|
2823
|
+
}
|
|
2824
|
+
function cloudReadSnapshotText(snapshot, relativePath) {
|
|
2825
|
+
const file = snapshot.get(relativePath);
|
|
2826
|
+
return file ? Buffer.from(file.contentBase64, "base64").toString("utf8") : "";
|
|
2827
|
+
}
|
|
2828
|
+
function cloudInferKind(snapshot) {
|
|
2829
|
+
const paths = [...snapshot.keys()];
|
|
2830
|
+
if (paths.some((file) => file === "TEAM.md" || file === "team.json" || /^(?:agents|team|departments|hr-departments)\//.test(file))) return "team";
|
|
2831
|
+
return "agent";
|
|
2832
|
+
}
|
|
2833
|
+
function cloudDetectRuntimeLabels(snapshot) {
|
|
2834
|
+
const paths = new Set(snapshot.keys());
|
|
2835
|
+
const labels = [];
|
|
2836
|
+
if (paths.has("CLAUDE.md") || [...paths].some((file) => file.startsWith(".claude/"))) labels.push("claude-code");
|
|
2837
|
+
if (paths.has("AGENTS.md")) labels.push("codex");
|
|
2838
|
+
if (paths.has("GEMINI.md")) labels.push("gemini");
|
|
2839
|
+
if (paths.has(".cursorrules") || [...paths].some((file) => file.startsWith(".cursor/"))) labels.push("cursor");
|
|
2840
|
+
return labels.length ? labels : ["generic"];
|
|
2841
|
+
}
|
|
2842
|
+
function cloudPackageDir(slug) {
|
|
2843
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2844
|
+
return path.join(userDataDir(), "cloud-agent-packages", `${slug}-${stamp}`);
|
|
2845
|
+
}
|
|
2846
|
+
function cloudPackageHashVersion(value) {
|
|
2847
|
+
if (value === undefined || value === null || value === "") return CLOUD_PACKAGE_HASH_V1;
|
|
2848
|
+
if (value === CLOUD_PACKAGE_HASH_V1 || value === CLOUD_PACKAGE_HASH_V2) return value;
|
|
2849
|
+
return null;
|
|
2850
|
+
}
|
|
2851
|
+
function cloudHashPackage(files, version = CLOUD_PACKAGE_HASH_V1) {
|
|
2852
|
+
const hashVersion = cloudPackageHashVersion(version);
|
|
2853
|
+
if (!hashVersion) throw new Error(`unsupported cloud package hash version: ${version}`);
|
|
2854
|
+
const h = crypto.createHash("sha256");
|
|
2855
|
+
// 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
|
|
2856
|
+
// 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
|
|
2857
|
+
// 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
|
|
2858
|
+
for (const file of [...files].sort(cloudCodePointPathOrder)) {
|
|
2859
|
+
h.update(file.path);
|
|
2860
|
+
h.update("\0");
|
|
2861
|
+
h.update(file.sha256);
|
|
2862
|
+
h.update("\0");
|
|
2863
|
+
if (hashVersion === CLOUD_PACKAGE_HASH_V2) {
|
|
2864
|
+
h.update(file.executable ? "x" : "-");
|
|
2865
|
+
h.update("\0");
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
return h.digest("hex");
|
|
2869
|
+
}
|
|
2870
|
+
function cloudCodePointPathOrder(a, b) {
|
|
2871
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
2872
|
+
}
|
|
2873
|
+
function cloudPortablePathKey(value) {
|
|
2874
|
+
return String(value).normalize("NFC").toLowerCase();
|
|
2875
|
+
}
|
|
2876
|
+
function cloudPortableRelativePath(value) {
|
|
2877
|
+
if (typeof value !== "string" || !value || value !== value.normalize("NFC")) return null;
|
|
2878
|
+
if (value.includes("\\") || value.includes("\0") || value.startsWith("/") || value.endsWith("/")) return null;
|
|
2879
|
+
if (value.includes("//") || value.length > 260) return null;
|
|
2880
|
+
const parts = value.split("/");
|
|
2881
|
+
for (const part of parts) {
|
|
2882
|
+
if (!part || part === "." || part === "..") return null;
|
|
2883
|
+
if (part.length > 255 || Buffer.byteLength(part, "utf8") > 255 || cloudHasUnpairedSurrogate(part)) return null;
|
|
2884
|
+
if (/[<>:"|?*\u0000-\u001f]/.test(part) || /[ .]$/.test(part)) return null;
|
|
2885
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) return null;
|
|
2886
|
+
}
|
|
2887
|
+
return value;
|
|
2888
|
+
}
|
|
2889
|
+
function cloudHasUnpairedSurrogate(value) {
|
|
2890
|
+
for (let index = 0; index < value.length; index++) {
|
|
2891
|
+
const unit = value.charCodeAt(index);
|
|
2892
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
2893
|
+
const next = value.charCodeAt(index + 1);
|
|
2894
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
|
2895
|
+
index++;
|
|
2896
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) return true;
|
|
2897
|
+
}
|
|
2898
|
+
return false;
|
|
2899
|
+
}
|
|
2900
|
+
function cloudPortablePathConflict(paths) {
|
|
2901
|
+
const files = new Map();
|
|
2902
|
+
const directories = new Map();
|
|
2903
|
+
for (const value of paths) {
|
|
2904
|
+
if (typeof value !== "string" || !value) continue;
|
|
2905
|
+
const fileKey = cloudPortablePathKey(value);
|
|
2906
|
+
const existingFile = files.get(fileKey);
|
|
2907
|
+
if (existingFile) {
|
|
2908
|
+
if (existingFile.path === value) {
|
|
2909
|
+
return { code: "duplicate-path", message: `Cloud package repeats file path ${JSON.stringify(value)}.` };
|
|
2910
|
+
}
|
|
2911
|
+
return { code: "path-alias-collision", message: `Cloud package paths ${JSON.stringify(existingFile.path)} and ${JSON.stringify(value)} alias after Unicode NFC normalization and case-folding.` };
|
|
2912
|
+
}
|
|
2913
|
+
files.set(fileKey, { path: value });
|
|
2914
|
+
const parts = value.split("/");
|
|
2915
|
+
for (let index = 1; index < parts.length; index++) {
|
|
2916
|
+
const directory = parts.slice(0, index).join("/");
|
|
2917
|
+
const directoryKey = cloudPortablePathKey(directory);
|
|
2918
|
+
const existingDirectory = directories.get(directoryKey);
|
|
2919
|
+
if (existingDirectory && existingDirectory.directory !== directory) {
|
|
2920
|
+
return {
|
|
2921
|
+
code: "path-alias-collision",
|
|
2922
|
+
message: `Ancestor directories ${JSON.stringify(existingDirectory.directory)} (from ${JSON.stringify(existingDirectory.sourcePath)}) and ${JSON.stringify(directory)} (from ${JSON.stringify(value)}) alias after Unicode NFC normalization and case-folding.`,
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
if (!existingDirectory) directories.set(directoryKey, { directory, sourcePath: value });
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
for (const [key, file] of files) {
|
|
2929
|
+
const directory = directories.get(key);
|
|
2930
|
+
if (!directory) continue;
|
|
2931
|
+
if (file.path === directory.directory) {
|
|
2932
|
+
return { code: "path-type-collision", message: `Cloud package path ${JSON.stringify(file.path)} is both a file and an ancestor directory.` };
|
|
2933
|
+
}
|
|
2934
|
+
return {
|
|
2935
|
+
code: "path-alias-collision",
|
|
2936
|
+
message: `File path ${JSON.stringify(file.path)} aliases ancestor directory ${JSON.stringify(directory.directory)} from ${JSON.stringify(directory.sourcePath)} after Unicode NFC normalization and case-folding.`,
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
return null;
|
|
2940
|
+
}
|
|
2941
|
+
function cloudReadRestoreExecutablePaths(rootPath) {
|
|
2942
|
+
if (process.platform !== "win32") return new Set();
|
|
2943
|
+
const marker = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
|
|
2944
|
+
try {
|
|
2945
|
+
const parsed = JSON.parse(fs.readFileSync(marker, "utf8"));
|
|
2946
|
+
if (cloudPackageHashVersion(parsed.packageHashVersion) !== CLOUD_PACKAGE_HASH_V2) return new Set();
|
|
2947
|
+
if (!Array.isArray(parsed.executablePaths)) return new Set();
|
|
2948
|
+
return new Set(parsed.executablePaths
|
|
2949
|
+
.filter((value) => cloudPortableRelativePath(value))
|
|
2950
|
+
.map((value) => cloudPortablePathKey(value)));
|
|
2951
|
+
} catch {
|
|
2952
|
+
return new Set();
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
function cloudPortableExecutableForFile(relativePath, statMode, restoredExecutablePaths, platform = process.platform) {
|
|
2956
|
+
if (platform === "win32") return restoredExecutablePaths.has(cloudPortablePathKey(relativePath));
|
|
2957
|
+
return Boolean(statMode & 0o111);
|
|
2958
|
+
}
|
|
2959
|
+
function cloudApplyPrivateDirectoryMode(directoryPath, platform = process.platform) {
|
|
2960
|
+
if (platform === "win32") return;
|
|
2961
|
+
fs.chmodSync(directoryPath, 0o700);
|
|
2962
|
+
const actual = fs.statSync(directoryPath).mode & 0o777;
|
|
2963
|
+
if (actual !== 0o700) throw new Error(`cloud restore directory mode verification failed: ${directoryPath}`);
|
|
2964
|
+
}
|
|
2965
|
+
function cloudApplyPortableFileMode(filePath, mode, platform = process.platform) {
|
|
2966
|
+
if (platform === "win32") return;
|
|
2967
|
+
fs.chmodSync(filePath, mode);
|
|
2968
|
+
const actual = fs.statSync(filePath).mode & 0o777;
|
|
2969
|
+
if (actual !== mode) throw new Error(`cloud restore file mode verification failed: ${filePath}`);
|
|
2970
|
+
}
|
|
2971
|
+
function cloudVerifyRestoredSnapshot(root, files, expected) {
|
|
2972
|
+
const expectedByPath = new Map(files.map((file) => [file.path, file]));
|
|
2973
|
+
const seen = new Set();
|
|
2974
|
+
function walk(dir) {
|
|
2975
|
+
const dirStat = fs.lstatSync(dir);
|
|
2976
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) throw new Error("cloud restore staging contains an unsafe directory");
|
|
2977
|
+
if (process.platform !== "win32" && (dirStat.mode & 0o777) !== 0o700) throw new Error("cloud restore staging directory mode mismatch");
|
|
2978
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2979
|
+
const absolute = path.join(dir, entry.name);
|
|
2980
|
+
const relative = path.relative(root, absolute).split(path.sep).join("/");
|
|
2981
|
+
if (relative === CLOUD_RESTORE_MARKER_PATH) continue;
|
|
2982
|
+
const stat = fs.lstatSync(absolute);
|
|
2983
|
+
if (stat.isSymbolicLink()) throw new Error("cloud restore staging contains a symbolic link");
|
|
2984
|
+
if (stat.isDirectory()) { walk(absolute); continue; }
|
|
2985
|
+
if (!stat.isFile()) throw new Error("cloud restore staging contains a special filesystem entry");
|
|
2986
|
+
const expectedFile = expectedByPath.get(relative);
|
|
2987
|
+
if (!expectedFile || seen.has(relative)) throw new Error(`cloud restore staging has an unexpected file: ${relative}`);
|
|
2988
|
+
const bytes = fs.readFileSync(absolute);
|
|
2989
|
+
if (bytes.length !== expectedFile.bytes || sha(bytes) !== expectedFile.sha256) {
|
|
2990
|
+
throw new Error(`cloud restore staging file integrity mismatch: ${relative}`);
|
|
2991
|
+
}
|
|
2992
|
+
if (process.platform !== "win32") {
|
|
2993
|
+
const mode = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2 && expectedFile.executable ? 0o700 : 0o600;
|
|
2994
|
+
if ((stat.mode & 0o777) !== mode) throw new Error(`cloud restore staging file mode mismatch: ${relative}`);
|
|
2995
|
+
}
|
|
2996
|
+
seen.add(relative);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
walk(root);
|
|
3000
|
+
if (seen.size !== expectedByPath.size) throw new Error("cloud restore staging is missing package files");
|
|
3001
|
+
const markerPath = path.join(root, CLOUD_RESTORE_MARKER_PATH);
|
|
3002
|
+
const markerStat = fs.lstatSync(markerPath);
|
|
3003
|
+
if (!markerStat.isFile() || markerStat.isSymbolicLink()) throw new Error("cloud restore marker is unsafe");
|
|
3004
|
+
if (process.platform !== "win32" && (markerStat.mode & 0o777) !== 0o600) throw new Error("cloud restore marker mode mismatch");
|
|
3005
|
+
const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
|
|
3006
|
+
const expectedExecutablePaths = expected.packageHashVersion === CLOUD_PACKAGE_HASH_V2
|
|
3007
|
+
? files.filter((file) => file.executable).map((file) => file.path).sort()
|
|
3008
|
+
: undefined;
|
|
3009
|
+
if (
|
|
3010
|
+
marker.schemaVersion !== 1 || marker.source !== "agentlas-cloud" || marker.slug !== expected.slug ||
|
|
3011
|
+
String(marker.packageHash).replace(/^sha256:/i, "").toLowerCase() !== expected.packageHash ||
|
|
3012
|
+
marker.packageHashVersion !== expected.packageHashVersion || marker.fileCount !== files.length ||
|
|
3013
|
+
marker.totalBytes !== expected.totalBytes || typeof marker.restoredAt !== "string" ||
|
|
3014
|
+
!Number.isFinite(Date.parse(marker.restoredAt)) ||
|
|
3015
|
+
JSON.stringify(marker.executablePaths) !== JSON.stringify(expectedExecutablePaths)
|
|
3016
|
+
) {
|
|
3017
|
+
throw new Error("cloud restore marker contract mismatch");
|
|
3018
|
+
}
|
|
3019
|
+
if (expected.assetDescriptor) {
|
|
3020
|
+
const descriptor = normalizeCloudAssetDescriptorCli(marker, "cloud restore marker");
|
|
3021
|
+
const nested = normalizeCloudAssetDescriptorCli(marker.cloudAssets?.[descriptor.scope], "cloud restore marker scope");
|
|
3022
|
+
if (
|
|
3023
|
+
JSON.stringify(descriptor) !== JSON.stringify(expected.assetDescriptor) ||
|
|
3024
|
+
JSON.stringify(nested) !== JSON.stringify(expected.assetDescriptor)
|
|
3025
|
+
) {
|
|
3026
|
+
throw new Error("cloud restore marker revision contract mismatch");
|
|
3027
|
+
}
|
|
1388
3028
|
}
|
|
1389
|
-
return "";
|
|
1390
3029
|
}
|
|
1391
|
-
function
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
3030
|
+
function cloudDecodeUtf16CredentialText(bytes) {
|
|
3031
|
+
if (bytes.length < 4) return null;
|
|
3032
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
3033
|
+
return bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2)).toString("utf16le");
|
|
3034
|
+
}
|
|
3035
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
3036
|
+
const body = Buffer.from(bytes.subarray(2, bytes.length - ((bytes.length - 2) % 2)));
|
|
3037
|
+
body.swap16();
|
|
3038
|
+
return body.toString("utf16le");
|
|
3039
|
+
}
|
|
3040
|
+
const sampleLength = Math.min(bytes.length - (bytes.length % 2), 4096);
|
|
3041
|
+
if (sampleLength < 8) return null;
|
|
3042
|
+
let oddNuls = 0;
|
|
3043
|
+
let evenNuls = 0;
|
|
3044
|
+
for (let index = 0; index < sampleLength; index += 2) {
|
|
3045
|
+
if (bytes[index] === 0) evenNuls++;
|
|
3046
|
+
if (bytes[index + 1] === 0) oddNuls++;
|
|
3047
|
+
}
|
|
3048
|
+
const pairs = sampleLength / 2;
|
|
3049
|
+
const fullLength = bytes.length - (bytes.length % 2);
|
|
3050
|
+
if (oddNuls / pairs > 0.3) return bytes.subarray(0, fullLength).toString("utf16le");
|
|
3051
|
+
if (evenNuls / pairs > 0.3) {
|
|
3052
|
+
const body = Buffer.from(bytes.subarray(0, fullLength));
|
|
3053
|
+
body.swap16();
|
|
3054
|
+
return body.toString("utf16le");
|
|
1398
3055
|
}
|
|
1399
|
-
return
|
|
3056
|
+
return null;
|
|
1400
3057
|
}
|
|
1401
|
-
function
|
|
1402
|
-
|
|
1403
|
-
|
|
3058
|
+
function cloudDecodeTextAsset(bytes) {
|
|
3059
|
+
const utf16 = cloudDecodeUtf16CredentialText(bytes);
|
|
3060
|
+
if (utf16 !== null) return { ok: true, text: utf16 };
|
|
3061
|
+
try {
|
|
3062
|
+
return { ok: true, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
|
|
3063
|
+
} catch {
|
|
3064
|
+
return { ok: false };
|
|
1404
3065
|
}
|
|
1405
|
-
return "agent";
|
|
1406
3066
|
}
|
|
1407
|
-
function
|
|
1408
|
-
|
|
1409
|
-
|
|
3067
|
+
function cloudCredentialValueLooksReal(rawValue) {
|
|
3068
|
+
let value = String(rawValue || "").trim().replace(/^['"]|['"]$/g, "").trim();
|
|
3069
|
+
try { value = decodeURIComponent(value); } catch { /* keep raw */ }
|
|
3070
|
+
if (value.length < 8) return false;
|
|
3071
|
+
if (/^(?:\$\{[^}]+\}|\$[A-Z_][A-Z0-9_]*|\{\{[^}]+\}\}|<[^>]+>)$/i.test(value)) return false;
|
|
3072
|
+
if (/^(?:process\.env\.|os\.environ|env\(|secret\(|vault:)/i.test(value)) return false;
|
|
3073
|
+
const compact = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
3074
|
+
if (/^(?:your|example|sample|dummy|placeholder|configure|configureonthismachine|changeme|replaceme|replacewith|redacted|masked|notareal|none|null|undefined|x+|star+)(?:api)?(?:key|secret|token|password)?(?:here)?$/.test(compact)) return false;
|
|
3075
|
+
if (/^(?:\*+|x+|_+|-+)$/.test(value)) return false;
|
|
3076
|
+
return true;
|
|
1410
3077
|
}
|
|
1411
|
-
function
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
3078
|
+
function cloudTextContainsStructuredCredential(text) {
|
|
3079
|
+
const assignment = /(?:^|\n)\s*["']?(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|token|password|passwd|pwd)["']?\s*[:=]\s*([^\r\n#;]+)/gi;
|
|
3080
|
+
for (const match of text.matchAll(assignment)) {
|
|
3081
|
+
if (cloudCredentialValueLooksReal(match[1])) return true;
|
|
3082
|
+
}
|
|
3083
|
+
const urlCredential = /\bhttps?:\/\/[^/\s:@]+:([^@\s/]{8,})@/gi;
|
|
3084
|
+
for (const match of text.matchAll(urlCredential)) {
|
|
3085
|
+
if (cloudCredentialValueLooksReal(match[1])) return true;
|
|
3086
|
+
}
|
|
3087
|
+
const queryCredential = /[?&](?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret|token|password)=([^&#\s]+)/gi;
|
|
3088
|
+
for (const match of text.matchAll(queryCredential)) {
|
|
3089
|
+
if (cloudCredentialValueLooksReal(match[1])) return true;
|
|
3090
|
+
}
|
|
3091
|
+
return false;
|
|
3092
|
+
}
|
|
3093
|
+
function cloudAddSecretFindingsFromBytes(bytes, relativePath, addFinding) {
|
|
3094
|
+
const candidates = new Set([bytes.toString("utf8")]);
|
|
3095
|
+
const utf16 = cloudDecodeUtf16CredentialText(bytes);
|
|
3096
|
+
if (utf16) candidates.add(utf16);
|
|
3097
|
+
for (const text of candidates) {
|
|
3098
|
+
for (const [id, re, label] of CLOUD_SECRET_RE) {
|
|
3099
|
+
if (re.test(text)) addFinding(id, "blocker", "secret", `Possible ${label} found in package content.`, relativePath, "Remove the value and require users to configure their own key.");
|
|
3100
|
+
}
|
|
3101
|
+
if (cloudTextContainsStructuredCredential(text)) {
|
|
3102
|
+
addFinding("generic-unquoted-secret", "blocker", "secret", "Possible unquoted or URL-embedded credential found in package content.", relativePath, "Replace the value with an environment/BYOK placeholder.");
|
|
3103
|
+
}
|
|
1421
3104
|
}
|
|
1422
|
-
|
|
3105
|
+
}
|
|
3106
|
+
function cloudCanonicalBase64(value) {
|
|
3107
|
+
if (typeof value !== "string") return false;
|
|
3108
|
+
if (value === "") return true;
|
|
3109
|
+
if (value.length % 4 !== 0) return false;
|
|
3110
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false;
|
|
3111
|
+
return Buffer.from(value, "base64").toString("base64") === value;
|
|
1423
3112
|
}
|
|
1424
3113
|
function cloudSecuritySummary(findings) {
|
|
1425
3114
|
const blockerCount = findings.filter((f) => f.severity === "blocker").length;
|
|
@@ -5958,67 +7647,154 @@ function resolveRuntime(db, override) {
|
|
|
5958
7647
|
|
|
5959
7648
|
// ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트 반환 ──
|
|
5960
7649
|
const DEFAULT_API_MODEL = {
|
|
5961
|
-
anthropic: "claude-sonnet-4-
|
|
7650
|
+
anthropic: "claude-sonnet-4-6",
|
|
5962
7651
|
openai: "gpt-4o-mini",
|
|
5963
7652
|
google: "gemini-1.5-flash",
|
|
5964
7653
|
ollama: "llama3.1",
|
|
5965
7654
|
upstage: "solar-pro2",
|
|
7655
|
+
custom: "deepseek-chat",
|
|
7656
|
+
glm: "glm-4.6",
|
|
7657
|
+
kimi: "kimi-k2-0711-preview",
|
|
7658
|
+
deepseek: "deepseek-chat",
|
|
7659
|
+
};
|
|
7660
|
+
const ANTHROPIC_COMPAT_API = {
|
|
7661
|
+
glm: { label: "GLM", baseUrl: "https://api.z.ai/api/anthropic" },
|
|
7662
|
+
kimi: { label: "Kimi", baseUrl: "https://api.moonshot.ai/anthropic" },
|
|
7663
|
+
deepseek: { label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic" },
|
|
5966
7664
|
};
|
|
7665
|
+
const DEFAULT_CUSTOM_API_BASE_URL = "https://api.openai.com/v1";
|
|
7666
|
+
|
|
5967
7667
|
async function apiKey(backend) {
|
|
5968
7668
|
const keytar = readKeytar();
|
|
5969
7669
|
if (!keytar) return null;
|
|
5970
7670
|
// 키체인 접근 거부(서명 안 된 standalone Node)는 "키 없음"으로 조용히 처리.
|
|
5971
7671
|
return keytar.getPassword(SERVICE, "byok:" + backend).catch(() => null);
|
|
5972
7672
|
}
|
|
5973
|
-
|
|
7673
|
+
|
|
7674
|
+
/**
|
|
7675
|
+
* Custom BYOK 키가 전송될 origin을 Terminal에서도 다시 검증한다.
|
|
7676
|
+
* Desktop IPC와 동일하게 공개 주소는 HTTPS만, HTTP는 localhost/LAN만 허용한다.
|
|
7677
|
+
*/
|
|
7678
|
+
function normalizeCustomApiBaseUrl(raw) {
|
|
7679
|
+
const value = String(raw || "").trim();
|
|
7680
|
+
if (!value) return DEFAULT_CUSTOM_API_BASE_URL;
|
|
7681
|
+
let parsed;
|
|
7682
|
+
try {
|
|
7683
|
+
parsed = new URL(value);
|
|
7684
|
+
} catch {
|
|
7685
|
+
throw new Error("Custom API base URL이 올바르지 않습니다.");
|
|
7686
|
+
}
|
|
7687
|
+
const host = parsed.hostname.toLowerCase();
|
|
7688
|
+
const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
7689
|
+
const isPrivateLan =
|
|
7690
|
+
/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
7691
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && (isLoopback || isPrivateLan))) {
|
|
7692
|
+
throw new Error("Custom API base URL은 HTTPS 또는 localhost/LAN의 HTTP여야 합니다.");
|
|
7693
|
+
}
|
|
7694
|
+
return value.replace(/\/+$/, "");
|
|
7695
|
+
}
|
|
7696
|
+
|
|
7697
|
+
/** Desktop과 공유하는 SQLite meta에서 Custom OpenAI base URL을 읽는다. */
|
|
7698
|
+
function readCustomApiBaseUrl() {
|
|
7699
|
+
const p = dbPath();
|
|
7700
|
+
if (!fs.existsSync(p)) return DEFAULT_CUSTOM_API_BASE_URL;
|
|
7701
|
+
let db = null;
|
|
7702
|
+
let raw = "";
|
|
7703
|
+
try {
|
|
7704
|
+
try {
|
|
7705
|
+
const Database = require("better-sqlite3");
|
|
7706
|
+
db = new Database(p, { readonly: true, fileMustExist: true });
|
|
7707
|
+
} catch {
|
|
7708
|
+
db = openNodeSqliteDb(p);
|
|
7709
|
+
}
|
|
7710
|
+
try {
|
|
7711
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'custom_base_url'").get();
|
|
7712
|
+
raw = row && row.value ? row.value : "";
|
|
7713
|
+
} catch {
|
|
7714
|
+
// 구버전 DB에 meta 테이블/키가 없으면 Desktop과 동일하게 OpenAI 기본 URL.
|
|
7715
|
+
raw = "";
|
|
7716
|
+
}
|
|
7717
|
+
} catch (e) {
|
|
7718
|
+
throw new Error(`Custom API base URL을 공유 DB에서 읽지 못했습니다: ${(e && e.message) || e}`);
|
|
7719
|
+
} finally {
|
|
7720
|
+
try { if (db && typeof db.close === "function") db.close(); } catch { /* ignore close failure */ }
|
|
7721
|
+
}
|
|
7722
|
+
return normalizeCustomApiBaseUrl(raw);
|
|
7723
|
+
}
|
|
7724
|
+
|
|
7725
|
+
/**
|
|
7726
|
+
* BYOK/Ollama 한 턴. 재사용 경로(swarm/automation)이므로 절대 process.exit하지 않고
|
|
7727
|
+
* 오류를 throw해 호출자의 catch/finally가 리스 해제·부분 실패를 처리하게 한다.
|
|
7728
|
+
* options는 회귀 테스트의 fetch/키 주입용이며 상용 호출자는 사용하지 않는다.
|
|
7729
|
+
*/
|
|
7730
|
+
async function runApi(backend, model, system, prompt, options) {
|
|
7731
|
+
options = options || {};
|
|
5974
7732
|
model = model || DEFAULT_API_MODEL[backend];
|
|
5975
|
-
|
|
7733
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
7734
|
+
if (typeof fetchImpl !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
|
|
5976
7735
|
if (backend === "ollama") {
|
|
5977
|
-
const resp = await
|
|
7736
|
+
const resp = await fetchImpl("http://127.0.0.1:11434/api/chat", {
|
|
5978
7737
|
method: "POST",
|
|
5979
7738
|
headers: { "content-type": "application/json" },
|
|
5980
7739
|
body: JSON.stringify({ model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
5981
7740
|
});
|
|
5982
|
-
if (!resp.ok)
|
|
7741
|
+
if (!resp.ok) throw new Error(`Ollama ${resp.status} — 'ollama serve' 실행/모델 확인`);
|
|
5983
7742
|
const j = await resp.json();
|
|
5984
7743
|
return (j.message && j.message.content) || "";
|
|
5985
7744
|
}
|
|
5986
|
-
const
|
|
5987
|
-
|
|
5988
|
-
if (
|
|
5989
|
-
|
|
7745
|
+
const supported = backend === "anthropic" || backend === "openai" || backend === "google" ||
|
|
7746
|
+
backend === "upstage" || backend === "custom" || !!ANTHROPIC_COMPAT_API[backend];
|
|
7747
|
+
if (!supported) throw new Error("지원하지 않는 backend: " + backend);
|
|
7748
|
+
const key = Object.prototype.hasOwnProperty.call(options, "apiKey") ? options.apiKey : await apiKey(backend);
|
|
7749
|
+
if (!key) throw new Error(`${backend} API 키가 없습니다. 앱 설정 → BYOK에서 키를 등록하세요.`);
|
|
7750
|
+
|
|
7751
|
+
const anthropicCompat = ANTHROPIC_COMPAT_API[backend];
|
|
7752
|
+
if (backend === "anthropic" || anthropicCompat) {
|
|
7753
|
+
const label = anthropicCompat ? anthropicCompat.label : "Anthropic";
|
|
7754
|
+
const base = anthropicCompat ? anthropicCompat.baseUrl : "https://api.anthropic.com";
|
|
7755
|
+
const authHeaders = anthropicCompat
|
|
7756
|
+
? { "x-api-key": key, authorization: "Bearer " + key }
|
|
7757
|
+
: { "x-api-key": key };
|
|
7758
|
+
const resp = await fetchImpl(`${base}/v1/messages`, {
|
|
5990
7759
|
method: "POST",
|
|
5991
|
-
headers: { "content-type": "application/json",
|
|
7760
|
+
headers: { "content-type": "application/json", ...authHeaders, "anthropic-version": "2023-06-01" },
|
|
5992
7761
|
body: JSON.stringify({ model, max_tokens: 4096, system, messages: [{ role: "user", content: prompt }] }),
|
|
5993
7762
|
});
|
|
5994
|
-
if (!resp.ok)
|
|
7763
|
+
if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
5995
7764
|
const j = await resp.json();
|
|
5996
7765
|
return (j.content && j.content[0] && j.content[0].text) || "";
|
|
5997
7766
|
}
|
|
5998
|
-
if (backend === "openai" || backend === "upstage") {
|
|
5999
|
-
const base = backend === "upstage"
|
|
6000
|
-
|
|
7767
|
+
if (backend === "openai" || backend === "upstage" || backend === "custom") {
|
|
7768
|
+
const base = backend === "upstage"
|
|
7769
|
+
? "https://api.upstage.ai/v1"
|
|
7770
|
+
: backend === "custom"
|
|
7771
|
+
? normalizeCustomApiBaseUrl(Object.prototype.hasOwnProperty.call(options, "customBaseUrl")
|
|
7772
|
+
? options.customBaseUrl
|
|
7773
|
+
: readCustomApiBaseUrl())
|
|
7774
|
+
: "https://api.openai.com/v1";
|
|
7775
|
+
const label = backend === "custom" ? "Custom API" : backend === "upstage" ? "Upstage" : "OpenAI";
|
|
7776
|
+
const resp = await fetchImpl(`${base}/chat/completions`, {
|
|
6001
7777
|
method: "POST",
|
|
6002
7778
|
headers: { "content-type": "application/json", authorization: "Bearer " + key },
|
|
6003
7779
|
body: JSON.stringify({ model, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
6004
7780
|
});
|
|
6005
|
-
if (!resp.ok)
|
|
7781
|
+
if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
6006
7782
|
const j = await resp.json();
|
|
6007
7783
|
return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
|
|
6008
7784
|
}
|
|
6009
7785
|
if (backend === "google") {
|
|
6010
7786
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
|
|
6011
|
-
const resp = await
|
|
7787
|
+
const resp = await fetchImpl(url, {
|
|
6012
7788
|
method: "POST",
|
|
6013
7789
|
headers: { "content-type": "application/json" },
|
|
6014
7790
|
body: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: "user", parts: [{ text: prompt }] }] }),
|
|
6015
7791
|
});
|
|
6016
|
-
if (!resp.ok)
|
|
7792
|
+
if (!resp.ok) throw new Error(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
6017
7793
|
const j = await resp.json();
|
|
6018
7794
|
const c = j.candidates && j.candidates[0];
|
|
6019
7795
|
return (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "";
|
|
6020
7796
|
}
|
|
6021
|
-
|
|
7797
|
+
throw new Error("지원하지 않는 backend: " + backend);
|
|
6022
7798
|
}
|
|
6023
7799
|
|
|
6024
7800
|
// 1회 실행 — CLI면 spawn(스트리밍 stdout), API면 호출 후 텍스트 출력. 종료코드 반환.
|
|
@@ -6039,7 +7815,7 @@ async function executeOnce(db, system, prompt, override, ctx) {
|
|
|
6039
7815
|
const { Ui } = require("./agentlas-ui.cjs");
|
|
6040
7816
|
const ui = new Ui({ lang: prefsLang() });
|
|
6041
7817
|
let mcpServers = [];
|
|
6042
|
-
if (permission
|
|
7818
|
+
if (permission === "full") {
|
|
6043
7819
|
try {
|
|
6044
7820
|
mcpServers = db.prepare("SELECT id, name, transport, command, args_json, enabled FROM mcp_servers WHERE enabled=1 AND transport='stdio'").all();
|
|
6045
7821
|
} catch { /* ignore */ }
|
|
@@ -6134,25 +7910,10 @@ function runCwd() {
|
|
|
6134
7910
|
}
|
|
6135
7911
|
|
|
6136
7912
|
function cliMcpConfigPath() {
|
|
6137
|
-
|
|
6138
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
6139
|
-
const file = path.join(dir, "agentlas-cli-mcp.json");
|
|
6140
|
-
fs.writeFileSync(
|
|
6141
|
-
file,
|
|
6142
|
-
JSON.stringify({
|
|
6143
|
-
mcpServers: {
|
|
6144
|
-
playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
|
|
6145
|
-
},
|
|
6146
|
-
}, null, 2),
|
|
6147
|
-
"utf8",
|
|
6148
|
-
);
|
|
6149
|
-
return file;
|
|
7913
|
+
return require("./agentlas-native-host.cjs").cliMcpConfigPath([]).file;
|
|
6150
7914
|
}
|
|
6151
7915
|
|
|
6152
|
-
const CODEX_PLAYWRIGHT_MCP_ARGS = [
|
|
6153
|
-
"-c", 'mcp_servers.playwright.command="npx"',
|
|
6154
|
-
"-c", 'mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]',
|
|
6155
|
-
];
|
|
7916
|
+
const CODEX_PLAYWRIGHT_MCP_ARGS = require("./agentlas-native-host.cjs").codexMcpArgs([]);
|
|
6156
7917
|
|
|
6157
7918
|
// 에이전트가 실제로 실행될 작업 폴더 = 사용자가 명령을 친 현재 디렉터리(= 대상 프로젝트).
|
|
6158
7919
|
// 단, home/userData/agent-cwd 같은 "프로젝트 아님" 위치면 안전한 전용 폴더로 폴백한다.
|
|
@@ -6231,14 +7992,40 @@ function readVaultEnvValuesCli(keys, projectPath) {
|
|
|
6231
7992
|
),
|
|
6232
7993
|
).then(() => result);
|
|
6233
7994
|
}
|
|
7995
|
+
|
|
7996
|
+
// 프로젝트/에이전트 dotenv는 일반 API 키 우선순위를 유지하되, 호스트 CLI의 신원·설치·
|
|
7997
|
+
// 플러그인 탐색 루트는 바꾸지 못한다. Windows 환경변수도 안전하게 대소문자 무관 비교한다.
|
|
7998
|
+
const PROTECTED_CHILD_ENV_KEYS_CLI = new Set([
|
|
7999
|
+
"HOME", "PATH", "PATHEXT", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
|
|
8000
|
+
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_SAFE_MODE",
|
|
8001
|
+
"AGENTLAS_CODEX_HOME", "AGENTLAS_USER_DATA_DIR",
|
|
8002
|
+
"CLAUDE_CODE_SIMPLE", "CLAUDE_PLUGIN_ROOT", "CLAUDE_PLUGIN_DATA", "CLAUDE_PROJECT_DIR",
|
|
8003
|
+
"GEMINI_CLI_HOME", "GEMINI_CLI_SYSTEM_SETTINGS_PATH", "GEMINI_CLI_USER_SETTINGS",
|
|
8004
|
+
"GEMINI_CLI_TRUSTED_FOLDERS_PATH", "GEMINI_CLI_TRUST_WORKSPACE", "GEMINI_CLI_EXTENSION_REGISTRY_URI",
|
|
8005
|
+
"HEPHAESTUS_RUNTIME_ROOT", "HEPHAESTUS_RUNTIME_BASE", "HEPHAESTUS_PYTHON", "HEPHAESTUS_AUTO_UPDATE",
|
|
8006
|
+
"HEPHAESTUS_UPDATE_CHECK", "NPM_CONFIG_PREFIX", "NODE_OPTIONS", "NODE_PATH",
|
|
8007
|
+
"PYTHONHOME", "PYTHONPATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH",
|
|
8008
|
+
"AGENTLAS_HUB_CONNECT_TIMEOUT_MS", "AGENTLAS_HUB_IDLE_TIMEOUT_MS", "AGENTLAS_HUB_TOTAL_TIMEOUT_MS",
|
|
8009
|
+
"AGENTLAS_NATIVE_IDLE_TIMEOUT_MS", "AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS", "AGENTLAS_NATIVE_KILL_GRACE_MS",
|
|
8010
|
+
"AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES",
|
|
8011
|
+
]);
|
|
8012
|
+
function isProtectedChildEnvKeyCli(key) {
|
|
8013
|
+
return PROTECTED_CHILD_ENV_KEYS_CLI.has(String(key || "").trim().toUpperCase());
|
|
8014
|
+
}
|
|
8015
|
+
function mergeChildEnvValuesCli(target, values, overwrite) {
|
|
8016
|
+
const injected = [];
|
|
8017
|
+
for (const [key, value] of Object.entries(values || {})) {
|
|
8018
|
+
if (!value || isProtectedChildEnvKeyCli(key)) continue;
|
|
8019
|
+
if (!overwrite && target[key]) continue;
|
|
8020
|
+
target[key] = value;
|
|
8021
|
+
injected.push(key);
|
|
8022
|
+
}
|
|
8023
|
+
return injected;
|
|
8024
|
+
}
|
|
6234
8025
|
async function buildChildEnvCli(db, ctx) {
|
|
6235
8026
|
const env = { ...process.env };
|
|
6236
8027
|
const apply = (values, overwrite) => {
|
|
6237
|
-
|
|
6238
|
-
if (!value) continue;
|
|
6239
|
-
if (!overwrite && env[key]) continue;
|
|
6240
|
-
env[key] = value;
|
|
6241
|
-
}
|
|
8028
|
+
mergeChildEnvValuesCli(env, values, overwrite);
|
|
6242
8029
|
};
|
|
6243
8030
|
const globalCredentials = {
|
|
6244
8031
|
...readDotEnvFileCli(path.join(userDataDir(), "credentials.env")),
|
|
@@ -6265,33 +8052,27 @@ async function buildChildEnvCli(db, ctx) {
|
|
|
6265
8052
|
return env;
|
|
6266
8053
|
}
|
|
6267
8054
|
|
|
6268
|
-
//
|
|
6269
|
-
//
|
|
8055
|
+
// One-shot/background capture uses the same permission truth as the interactive host.
|
|
8056
|
+
// Keep its plain-output argument shape, but never duplicate the security mapping here.
|
|
6270
8057
|
function buildArgs(kind, systemPrompt, prompt, permission) {
|
|
8058
|
+
const native = require("./agentlas-native-host.cjs");
|
|
8059
|
+
const level = require("./agentlas-permissions.cjs").normalize(permission);
|
|
6271
8060
|
if (kind === "claude-code") {
|
|
6272
|
-
const perm =
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
? ["--permission-mode", "acceptEdits"]
|
|
6277
|
-
: [];
|
|
6278
|
-
const mcp = permission === "write" || permission === "full"
|
|
6279
|
-
? ["--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
|
|
6280
|
-
: [];
|
|
8061
|
+
const perm = native.claudePermissionArgs(level);
|
|
8062
|
+
const mcp = level === "full"
|
|
8063
|
+
? ["--strict-mcp-config", "--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
|
|
8064
|
+
: native.claudeMcpIsolationArgs();
|
|
6281
8065
|
return ["-p", prompt, "--append-system-prompt", systemPrompt, ...perm, ...mcp];
|
|
6282
8066
|
}
|
|
6283
8067
|
if (kind === "codex") {
|
|
6284
|
-
|
|
6285
|
-
const
|
|
6286
|
-
permission === "full" || permission === "write"
|
|
6287
|
-
? ["--dangerously-bypass-approvals-and-sandbox"]
|
|
6288
|
-
: ["--sandbox", "read-only", "--ask-for-approval", "never"];
|
|
6289
|
-
const mcp = permission === "write" || permission === "full" ? CODEX_PLAYWRIGHT_MCP_ARGS : [];
|
|
8068
|
+
const perm = native.codexPermissionArgs(level);
|
|
8069
|
+
const mcp = level === "full" ? CODEX_PLAYWRIGHT_MCP_ARGS : [];
|
|
6290
8070
|
return ["exec", "--skip-git-repo-check", ...perm, ...mcp, `[SYSTEM]\n${systemPrompt}\n\n${prompt}`];
|
|
6291
8071
|
}
|
|
6292
8072
|
if (kind === "gemini") {
|
|
6293
|
-
const perm =
|
|
6294
|
-
|
|
8073
|
+
const perm = native.geminiPermissionArgs(level);
|
|
8074
|
+
const mcp = level === "full" ? [] : native.geminiMcpIsolationArgs();
|
|
8075
|
+
return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...perm, ...mcp];
|
|
6295
8076
|
}
|
|
6296
8077
|
return [prompt];
|
|
6297
8078
|
}
|
|
@@ -6307,7 +8088,7 @@ function launchInteractive(db, agent, runtimeOverride) {
|
|
|
6307
8088
|
id: agent.id,
|
|
6308
8089
|
slug: agent.slug,
|
|
6309
8090
|
label: agent.name,
|
|
6310
|
-
system: agent
|
|
8091
|
+
system: agentSystemPromptCli(agent),
|
|
6311
8092
|
capAgent: agent,
|
|
6312
8093
|
};
|
|
6313
8094
|
return launchTui(db, subject, runtimeOverride);
|
|
@@ -6338,7 +8119,7 @@ function buildHelpers(db) {
|
|
|
6338
8119
|
autoRoutePreamble: (choice, lang) => autoRoutePreamble(choice, lang),
|
|
6339
8120
|
cliMemoryContext: (db_, pp) => cliMemoryContext(db_, pp),
|
|
6340
8121
|
importLocal: (db_, p) => importLocalFolderCli(db_, p),
|
|
6341
|
-
// REPL-safe
|
|
8122
|
+
// REPL-safe public Hub install: fail()(process.exit) 대신 Error를 throw 해 REPL이 직접 렌더하게 한다.
|
|
6342
8123
|
cloudInstall: async (db_, slug) => {
|
|
6343
8124
|
if (typeof fetch !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임 필요).");
|
|
6344
8125
|
const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
|
|
@@ -6347,22 +8128,25 @@ function buildHelpers(db) {
|
|
|
6347
8128
|
if (cookie) headers.cookie = cookie;
|
|
6348
8129
|
let resp;
|
|
6349
8130
|
try {
|
|
6350
|
-
resp = await
|
|
8131
|
+
resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
|
|
6351
8132
|
method: "POST",
|
|
6352
8133
|
headers,
|
|
6353
8134
|
body: JSON.stringify({ method: "marketplace.get_manifest", params: { name: "marketplace.get_manifest", arguments: { kind: "agent", slug } } }),
|
|
6354
8135
|
});
|
|
6355
8136
|
} catch (e) {
|
|
6356
|
-
throw new Error(
|
|
8137
|
+
throw new Error(`Hub 연결 실패: ${(e && e.message) || e}`);
|
|
6357
8138
|
}
|
|
6358
8139
|
if (!resp.ok) {
|
|
6359
8140
|
const authHint = resp.status === 401 || resp.status === 403 ? " — 로그인이 필요합니다 (앱에서 로그인 또는 AGENTLAS_SESSION 설정)" : "";
|
|
6360
|
-
throw new Error(
|
|
8141
|
+
throw new Error(`Hub 응답 ${resp.status}${authHint}`);
|
|
6361
8142
|
}
|
|
6362
|
-
const json =
|
|
6363
|
-
if (json.error) throw new Error(json.error.message || "
|
|
8143
|
+
const json = parseHubJsonCli(resp, "marketplace.get_manifest");
|
|
8144
|
+
if (json.error) throw new Error(json.error.message || "Hub error");
|
|
6364
8145
|
const listing = json.result;
|
|
6365
|
-
if (!listing) throw new Error(
|
|
8146
|
+
if (!listing) throw new Error(`Hub에서 찾을 수 없음: ${slug}`);
|
|
8147
|
+
if (listing.delivery && listing.delivery.mode === "call_only") {
|
|
8148
|
+
throw new Error(`이 Hub 에이전트는 call-only 자산입니다. 실행: agentlas call ${slug}`);
|
|
8149
|
+
}
|
|
6366
8150
|
return persistCloudListingCli(db_, listing);
|
|
6367
8151
|
},
|
|
6368
8152
|
hasCloudSession: async () => {
|
|
@@ -6473,10 +8257,11 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
|
|
|
6473
8257
|
const cwd = opts.cwd || runCwd();
|
|
6474
8258
|
return new Promise((resolve) => {
|
|
6475
8259
|
const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
|
|
8260
|
+
const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
|
|
6476
8261
|
const child = spawn(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
|
|
6477
8262
|
cwd,
|
|
6478
8263
|
stdio: ["ignore", "inherit", "inherit"],
|
|
6479
|
-
env
|
|
8264
|
+
env,
|
|
6480
8265
|
});
|
|
6481
8266
|
child.on("error", (err) => {
|
|
6482
8267
|
process.stderr.write(`\n실행 실패(${kind}): ${err.message}\n`);
|
|
@@ -6486,32 +8271,160 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
|
|
|
6486
8271
|
});
|
|
6487
8272
|
}
|
|
6488
8273
|
|
|
8274
|
+
const CAPTURE_OUTPUT_DEFAULT_BYTES = 4 * 1024 * 1024;
|
|
8275
|
+
function captureOutputLimit(env = process.env) {
|
|
8276
|
+
return finiteTimeoutMs(env.AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES, CAPTURE_OUTPUT_DEFAULT_BYTES, 64 * 1024, 32 * 1024 * 1024);
|
|
8277
|
+
}
|
|
8278
|
+
function directCaptureOutputLimit(value) {
|
|
8279
|
+
return finiteTimeoutMs(value, CAPTURE_OUTPUT_DEFAULT_BYTES, 128, 32 * 1024 * 1024);
|
|
8280
|
+
}
|
|
8281
|
+
|
|
6489
8282
|
function captureRuntime(kind, systemPrompt, prompt, opts) {
|
|
6490
8283
|
opts = opts || {};
|
|
6491
8284
|
const cwd = opts.cwd || runCwd();
|
|
8285
|
+
const { nativeTimeoutConfig, directNativeTimeoutConfig } = require("./agentlas-native-host.cjs");
|
|
8286
|
+
const timeout = opts.timeoutConfig
|
|
8287
|
+
? directNativeTimeoutConfig(opts.timeoutConfig)
|
|
8288
|
+
: nativeTimeoutConfig(opts.env || process.env);
|
|
8289
|
+
const outputLimit = opts.outputLimitBytes == null
|
|
8290
|
+
? captureOutputLimit(opts.env || process.env)
|
|
8291
|
+
: directCaptureOutputLimit(opts.outputLimitBytes);
|
|
6492
8292
|
return new Promise((resolve, reject) => {
|
|
6493
8293
|
const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
env
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
})
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
}
|
|
6507
|
-
|
|
6508
|
-
|
|
8294
|
+
let child;
|
|
8295
|
+
try {
|
|
8296
|
+
const spawnImpl = opts.spawn || spawn;
|
|
8297
|
+
const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
|
|
8298
|
+
child = spawnImpl(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
|
|
8299
|
+
cwd,
|
|
8300
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
8301
|
+
env,
|
|
8302
|
+
});
|
|
8303
|
+
} catch (error) {
|
|
8304
|
+
reject(error);
|
|
8305
|
+
return;
|
|
8306
|
+
}
|
|
8307
|
+
|
|
8308
|
+
const stdoutChunks = [];
|
|
8309
|
+
const stderrChunks = [];
|
|
8310
|
+
let capturedBytes = 0;
|
|
8311
|
+
let settled = false;
|
|
8312
|
+
let terminationError = null;
|
|
8313
|
+
let idleTimer = null;
|
|
8314
|
+
let totalTimer = null;
|
|
8315
|
+
let killTimer = null;
|
|
8316
|
+
let forceTimer = null;
|
|
8317
|
+
let onStdout = () => {};
|
|
8318
|
+
let onStderr = () => {};
|
|
8319
|
+
let onError = () => {};
|
|
8320
|
+
let onClose = () => {};
|
|
8321
|
+
let onAbort = () => {};
|
|
8322
|
+
|
|
8323
|
+
const clearTimers = () => {
|
|
8324
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
8325
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
8326
|
+
if (killTimer) clearTimeout(killTimer);
|
|
8327
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
8328
|
+
idleTimer = totalTimer = killTimer = forceTimer = null;
|
|
8329
|
+
};
|
|
8330
|
+
const cleanup = () => {
|
|
8331
|
+
clearTimers();
|
|
8332
|
+
child.stdout?.removeListener("data", onStdout);
|
|
8333
|
+
child.stderr?.removeListener("data", onStderr);
|
|
8334
|
+
child.removeListener("error", onError);
|
|
8335
|
+
child.removeListener("close", onClose);
|
|
8336
|
+
if (opts.signal) opts.signal.removeEventListener?.("abort", onAbort);
|
|
8337
|
+
};
|
|
8338
|
+
const finishReject = (error) => {
|
|
8339
|
+
if (settled) return;
|
|
8340
|
+
settled = true;
|
|
8341
|
+
cleanup();
|
|
8342
|
+
reject(error);
|
|
8343
|
+
};
|
|
8344
|
+
const finishResolve = (value) => {
|
|
8345
|
+
if (settled) return;
|
|
8346
|
+
settled = true;
|
|
8347
|
+
cleanup();
|
|
8348
|
+
resolve(value);
|
|
8349
|
+
};
|
|
8350
|
+
const requestStop = (error) => {
|
|
8351
|
+
if (settled || terminationError) return;
|
|
8352
|
+
terminationError = error;
|
|
8353
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
8354
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
8355
|
+
idleTimer = totalTimer = null;
|
|
8356
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
8357
|
+
if (settled) return;
|
|
8358
|
+
killTimer = setTimeout(() => {
|
|
8359
|
+
if (settled) return;
|
|
8360
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
8361
|
+
if (settled) return;
|
|
8362
|
+
forceTimer = setTimeout(() => finishReject(terminationError), Math.max(250, Math.min(1_000, timeout.killGraceMs)));
|
|
8363
|
+
}, timeout.killGraceMs);
|
|
8364
|
+
};
|
|
8365
|
+
const timeoutError = (phase, ms) => {
|
|
8366
|
+
const error = new Error(
|
|
8367
|
+
phase === "idle"
|
|
8368
|
+
? `${kind} capture idle timeout: ${ms}ms 동안 출력이 없습니다.`
|
|
8369
|
+
: `${kind} capture total timeout: 전체 실행 시간이 ${ms}ms를 초과했습니다.`,
|
|
8370
|
+
);
|
|
8371
|
+
error.code = `AGENTLAS_CAPTURE_${phase.toUpperCase()}_TIMEOUT`;
|
|
8372
|
+
return error;
|
|
8373
|
+
};
|
|
8374
|
+
const armIdle = () => {
|
|
8375
|
+
if (settled || terminationError) return;
|
|
8376
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
8377
|
+
idleTimer = setTimeout(() => requestStop(timeoutError("idle", timeout.idleMs)), timeout.idleMs);
|
|
8378
|
+
};
|
|
8379
|
+
const append = (target, chunk) => {
|
|
8380
|
+
armIdle();
|
|
8381
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
8382
|
+
const remaining = Math.max(0, outputLimit - capturedBytes);
|
|
8383
|
+
if (remaining > 0) {
|
|
8384
|
+
const kept = bytes.length > remaining ? bytes.subarray(0, remaining) : bytes;
|
|
8385
|
+
target.push(kept);
|
|
8386
|
+
capturedBytes += kept.length;
|
|
8387
|
+
}
|
|
8388
|
+
if (bytes.length > remaining) {
|
|
8389
|
+
const error = new Error(`${kind} capture output limit: ${outputLimit} bytes를 초과했습니다.`);
|
|
8390
|
+
error.code = "AGENTLAS_CAPTURE_OUTPUT_LIMIT";
|
|
8391
|
+
requestStop(error);
|
|
8392
|
+
}
|
|
8393
|
+
};
|
|
8394
|
+
|
|
8395
|
+
onStdout = (chunk) => append(stdoutChunks, chunk);
|
|
8396
|
+
onStderr = (chunk) => append(stderrChunks, chunk);
|
|
8397
|
+
onError = (error) => finishReject(terminationError || error);
|
|
8398
|
+
onClose = (code) => {
|
|
8399
|
+
if (terminationError) {
|
|
8400
|
+
finishReject(terminationError);
|
|
8401
|
+
return;
|
|
8402
|
+
}
|
|
8403
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
8404
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
6509
8405
|
if (code && code !== 0) {
|
|
6510
|
-
|
|
8406
|
+
finishReject(new Error(`${kind} exited ${code}: ${stderr.slice(-500)}`));
|
|
6511
8407
|
return;
|
|
6512
8408
|
}
|
|
6513
|
-
|
|
6514
|
-
}
|
|
8409
|
+
finishResolve(stdout.trim() || stderr.trim());
|
|
8410
|
+
};
|
|
8411
|
+
onAbort = () => {
|
|
8412
|
+
const reason = opts.signal && opts.signal.reason;
|
|
8413
|
+
const error = reason instanceof Error ? reason : new Error(`${kind} capture aborted`);
|
|
8414
|
+
if (!error.code) error.code = "ABORT_ERR";
|
|
8415
|
+
requestStop(error);
|
|
8416
|
+
};
|
|
8417
|
+
|
|
8418
|
+
child.stdout.on("data", onStdout);
|
|
8419
|
+
child.stderr.on("data", onStderr);
|
|
8420
|
+
child.on("error", onError);
|
|
8421
|
+
child.on("close", onClose);
|
|
8422
|
+
armIdle();
|
|
8423
|
+
totalTimer = setTimeout(() => requestStop(timeoutError("total", timeout.totalMs)), timeout.totalMs);
|
|
8424
|
+
if (opts.signal) {
|
|
8425
|
+
if (opts.signal.aborted) onAbort();
|
|
8426
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
8427
|
+
}
|
|
6515
8428
|
});
|
|
6516
8429
|
}
|
|
6517
8430
|
|
|
@@ -6578,7 +8491,7 @@ function cmdList(db) {
|
|
|
6578
8491
|
|
|
6579
8492
|
function ensureNativeFiles(agent, folder) {
|
|
6580
8493
|
fs.mkdirSync(folder, { recursive: true });
|
|
6581
|
-
const sys = agent
|
|
8494
|
+
const sys = agentSystemPromptCli(agent);
|
|
6582
8495
|
writeIfMissing(path.join(folder, "system-prompt.md"), sys);
|
|
6583
8496
|
const header = `# ${agent.name}\n\n${agent.tagline || ""}\n\n${sys}\n`;
|
|
6584
8497
|
// 네이티브 CLI가 프로젝트 지시로 자동 인식하는 파일들
|
|
@@ -6615,7 +8528,7 @@ async function cmdRun(db, query, prompt, runtimeOverride) {
|
|
|
6615
8528
|
if (!userPrompt) userPrompt = await readStdin();
|
|
6616
8529
|
if (!userPrompt || !userPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 stdin으로 전달하세요.");
|
|
6617
8530
|
process.stderr.write(`▸ ${agent.name}\n`);
|
|
6618
|
-
const code = await executeOnce(db, agent
|
|
8531
|
+
const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION });
|
|
6619
8532
|
process.exit(code);
|
|
6620
8533
|
}
|
|
6621
8534
|
|
|
@@ -6625,7 +8538,7 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
|
|
|
6625
8538
|
if (!choice) fail("자동 라우팅할 에이전트가 없습니다. agentlas list로 설치 상태를 확인하세요.");
|
|
6626
8539
|
process.stderr.write(`▸ ${choice.agent.name} (auto)\n`);
|
|
6627
8540
|
process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
|
|
6628
|
-
const sys = `${autoRoutePreamble(choice, lang)}\n\n${choice.agent
|
|
8541
|
+
const sys = `${autoRoutePreamble(choice, lang)}\n\n${agentSystemPromptCli(choice.agent)}`;
|
|
6629
8542
|
const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
|
|
6630
8543
|
projectPath: activeProjectPath(db),
|
|
6631
8544
|
agentId: choice.agent.id,
|
|
@@ -6722,7 +8635,15 @@ function upsertEnvLine(file, key, value) {
|
|
|
6722
8635
|
if (re.test(body)) body = body.replace(re, line);
|
|
6723
8636
|
else body = body ? body.replace(/\n?$/, "\n") + line + "\n" : line + "\n";
|
|
6724
8637
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
6725
|
-
|
|
8638
|
+
// 이 헬퍼의 모든 호출자는 credential 값/경로를 기록한다. 새 파일뿐 아니라 기존 0644
|
|
8639
|
+
// 파일도 매번 0600으로 수렴시켜 같은 머신의 다른 계정이 읽지 못하게 한다.
|
|
8640
|
+
fs.writeFileSync(file, body, { encoding: "utf8", mode: 0o600 });
|
|
8641
|
+
try { fs.chmodSync(file, 0o600); } catch { /* Windows/읽기전용 FS best-effort */ }
|
|
8642
|
+
}
|
|
8643
|
+
function resolveCredentialSourcePath(source, cwd) {
|
|
8644
|
+
// `agentlas creds file`은 일반 CLI 명령이므로 상대경로 기준은 사용자가 명령을 실행한
|
|
8645
|
+
// 셸 cwd다. 런타임 격리용 agent-cwd를 쓰면 실제 프로젝트 파일을 조용히 못 찾는다.
|
|
8646
|
+
return path.resolve(cwd || process.cwd(), source);
|
|
6726
8647
|
}
|
|
6727
8648
|
async function cmdCredsFile(db, args) {
|
|
6728
8649
|
const f = parseCredFlags(args);
|
|
@@ -6740,7 +8661,7 @@ async function cmdCredsFile(db, args) {
|
|
|
6740
8661
|
ensureLocalCredentialStoreCli(project, projectName, arch);
|
|
6741
8662
|
ensureSoulCredentialIndexCli(project, projectName, arch);
|
|
6742
8663
|
|
|
6743
|
-
const sourceAbs =
|
|
8664
|
+
const sourceAbs = resolveCredentialSourcePath(source);
|
|
6744
8665
|
let stat;
|
|
6745
8666
|
try { stat = fs.statSync(sourceAbs); } catch { fail(`credential source not found: ${source}`); }
|
|
6746
8667
|
if (!stat.isFile()) fail(`credential source is not a file: ${source}`);
|
|
@@ -6993,48 +8914,220 @@ function cmdUpdateHelp() {
|
|
|
6993
8914
|
);
|
|
6994
8915
|
}
|
|
6995
8916
|
|
|
6996
|
-
function
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
8917
|
+
function macReleaseArch() {
|
|
8918
|
+
if (process.arch === "arm64") return "arm64";
|
|
8919
|
+
if (process.arch === "x64") return "x64";
|
|
8920
|
+
return null;
|
|
8921
|
+
}
|
|
8922
|
+
|
|
8923
|
+
const UPDATE_METADATA_MAX_BYTES = 1024 * 1024;
|
|
8924
|
+
const UPDATE_DOWNLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
8925
|
+
const UPDATE_TIMEOUT_DEFAULTS = Object.freeze({
|
|
8926
|
+
metadata: Object.freeze({ connectMs: 15_000, idleMs: 15_000, totalMs: 30_000 }),
|
|
8927
|
+
download: Object.freeze({ connectMs: 20_000, idleMs: 60_000, totalMs: 30 * 60_000 }),
|
|
8928
|
+
});
|
|
8929
|
+
|
|
8930
|
+
function updateTimeoutConfig(env = process.env, kind = "download") {
|
|
8931
|
+
const selected = kind === "metadata" ? "metadata" : "download";
|
|
8932
|
+
const defaults = UPDATE_TIMEOUT_DEFAULTS[selected];
|
|
8933
|
+
const prefix = selected === "metadata" ? "AGENTLAS_UPDATE_METADATA" : "AGENTLAS_UPDATE_DOWNLOAD";
|
|
8934
|
+
const totalMs = finiteTimeoutMs(env[`${prefix}_TOTAL_TIMEOUT_MS`], defaults.totalMs, 5_000, 60 * 60_000);
|
|
8935
|
+
return {
|
|
8936
|
+
connectMs: Math.min(totalMs, finiteTimeoutMs(env[`${prefix}_CONNECT_TIMEOUT_MS`], defaults.connectMs, 1_000, 120_000)),
|
|
8937
|
+
idleMs: Math.min(totalMs, finiteTimeoutMs(env[`${prefix}_IDLE_TIMEOUT_MS`], defaults.idleMs, 1_000, 300_000)),
|
|
8938
|
+
totalMs,
|
|
8939
|
+
};
|
|
7006
8940
|
}
|
|
7007
8941
|
|
|
7008
|
-
function
|
|
7009
|
-
const
|
|
7010
|
-
const
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
8942
|
+
function directUpdateTimeoutConfig(value = {}, kind = "download") {
|
|
8943
|
+
const defaults = UPDATE_TIMEOUT_DEFAULTS[kind === "metadata" ? "metadata" : "download"];
|
|
8944
|
+
const totalMs = finiteTimeoutMs(value.totalMs, defaults.totalMs, 10, 60 * 60_000);
|
|
8945
|
+
return {
|
|
8946
|
+
connectMs: Math.min(totalMs, finiteTimeoutMs(value.connectMs, defaults.connectMs, 10, 120_000)),
|
|
8947
|
+
idleMs: Math.min(totalMs, finiteTimeoutMs(value.idleMs, defaults.idleMs, 10, 300_000)),
|
|
8948
|
+
totalMs,
|
|
8949
|
+
};
|
|
8950
|
+
}
|
|
8951
|
+
|
|
8952
|
+
function updateDownloadMaxBytes(env = process.env) {
|
|
8953
|
+
return finiteTimeoutMs(env.AGENTLAS_UPDATE_DOWNLOAD_MAX_BYTES, UPDATE_DOWNLOAD_MAX_BYTES, 16 * 1024 * 1024, 2 * 1024 * 1024 * 1024);
|
|
8954
|
+
}
|
|
8955
|
+
|
|
8956
|
+
function updateTransferError(code, message, cause) {
|
|
8957
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
8958
|
+
error.code = code;
|
|
8959
|
+
return error;
|
|
8960
|
+
}
|
|
8961
|
+
|
|
8962
|
+
function updateTimeoutError(kind, ms) {
|
|
8963
|
+
const message = kind === "connect"
|
|
8964
|
+
? `업데이트 서버 연결 제한 시간(${ms}ms)을 초과했습니다.`
|
|
8965
|
+
: kind === "idle"
|
|
8966
|
+
? `업데이트 전송이 ${ms}ms 동안 멈췄습니다.`
|
|
8967
|
+
: `업데이트 요청 전체 제한 시간(${ms}ms)을 초과했습니다.`;
|
|
8968
|
+
return updateTransferError(`AGENTLAS_UPDATE_${kind.toUpperCase()}_TIMEOUT`, message);
|
|
8969
|
+
}
|
|
8970
|
+
|
|
8971
|
+
function parseSafeUpdateUrl(value, label = "업데이트 URL") {
|
|
8972
|
+
let parsed;
|
|
8973
|
+
try {
|
|
8974
|
+
parsed = new URL(String(value || ""));
|
|
8975
|
+
} catch (error) {
|
|
8976
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label} 형식이 올바르지 않습니다.`, error);
|
|
8977
|
+
}
|
|
8978
|
+
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
|
|
8979
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
8980
|
+
throw updateTransferError("AGENTLAS_UPDATE_INSECURE_URL", `${label}은 HTTPS여야 합니다(로컬 루프백 제외).`);
|
|
8981
|
+
}
|
|
8982
|
+
if (parsed.username || parsed.password) {
|
|
8983
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_URL", `${label}에 사용자 정보가 포함될 수 없습니다.`);
|
|
8984
|
+
}
|
|
8985
|
+
return parsed.toString();
|
|
8986
|
+
}
|
|
8987
|
+
|
|
8988
|
+
/** Headers 전 connect, chunk 사이 idle, 전체 total 제한을 적용하는 bounded 스트림 reader. */
|
|
8989
|
+
async function consumeUpdateResponse(url, init = {}, options = {}) {
|
|
8990
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
8991
|
+
if (typeof fetchImpl !== "function") throw updateTransferError("AGENTLAS_UPDATE_FETCH_UNAVAILABLE", "이 런타임에 fetch가 없습니다.");
|
|
8992
|
+
const kind = options.kind === "metadata" ? "metadata" : "download";
|
|
8993
|
+
const timeout = options.timeoutConfig
|
|
8994
|
+
? directUpdateTimeoutConfig(options.timeoutConfig, kind)
|
|
8995
|
+
: updateTimeoutConfig(options.env || process.env, kind);
|
|
8996
|
+
const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0
|
|
8997
|
+
? options.maxBytes
|
|
8998
|
+
: kind === "metadata" ? UPDATE_METADATA_MAX_BYTES : updateDownloadMaxBytes(options.env || process.env);
|
|
8999
|
+
const expectedBytes = options.expectedBytes == null ? null : Number(options.expectedBytes);
|
|
9000
|
+
const controller = new AbortController();
|
|
9001
|
+
const upstreamSignal = init.signal;
|
|
9002
|
+
let connectTimer = null;
|
|
9003
|
+
let idleTimer = null;
|
|
9004
|
+
let totalTimer = null;
|
|
9005
|
+
let reader = null;
|
|
9006
|
+
let terminalError = null;
|
|
9007
|
+
let caughtError = null;
|
|
9008
|
+
let rejectTerminal;
|
|
9009
|
+
const terminal = new Promise((_, reject) => { rejectTerminal = reject; });
|
|
9010
|
+
const stop = (error) => {
|
|
9011
|
+
if (terminalError) return;
|
|
9012
|
+
terminalError = error;
|
|
9013
|
+
try { controller.abort(error); } catch { controller.abort(); }
|
|
9014
|
+
rejectTerminal(error);
|
|
9015
|
+
};
|
|
9016
|
+
const onUpstreamAbort = () => {
|
|
9017
|
+
const reason = upstreamSignal && upstreamSignal.reason;
|
|
9018
|
+
const error = reason instanceof Error ? reason : updateTransferError("ABORT_ERR", "업데이트 요청이 취소되었습니다.");
|
|
9019
|
+
if (!error.code) error.code = "ABORT_ERR";
|
|
9020
|
+
stop(error);
|
|
9021
|
+
};
|
|
9022
|
+
const armIdle = () => {
|
|
9023
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
9024
|
+
idleTimer = setTimeout(() => stop(updateTimeoutError("idle", timeout.idleMs)), timeout.idleMs);
|
|
9025
|
+
};
|
|
9026
|
+
|
|
9027
|
+
if (upstreamSignal) {
|
|
9028
|
+
if (upstreamSignal.aborted) onUpstreamAbort();
|
|
9029
|
+
else upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
|
|
9030
|
+
}
|
|
9031
|
+
connectTimer = setTimeout(() => stop(updateTimeoutError("connect", timeout.connectMs)), timeout.connectMs);
|
|
9032
|
+
totalTimer = setTimeout(() => stop(updateTimeoutError("total", timeout.totalMs)), timeout.totalMs);
|
|
9033
|
+
|
|
9034
|
+
try {
|
|
9035
|
+
const response = await Promise.race([
|
|
9036
|
+
Promise.resolve().then(() => fetchImpl(url, { ...init, signal: controller.signal })),
|
|
9037
|
+
terminal,
|
|
9038
|
+
]);
|
|
9039
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
9040
|
+
connectTimer = null;
|
|
9041
|
+
if (!response || typeof response.ok !== "boolean") {
|
|
9042
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_RESPONSE", "업데이트 서버 응답 형식이 올바르지 않습니다.");
|
|
9043
|
+
}
|
|
9044
|
+
if (response.url) parseSafeUpdateUrl(response.url, "리디렉션된 업데이트 URL");
|
|
9045
|
+
if (!response.ok) {
|
|
9046
|
+
throw updateTransferError("AGENTLAS_UPDATE_HTTP_ERROR", `업데이트 요청 실패: HTTP ${response.status}`);
|
|
9047
|
+
}
|
|
9048
|
+
const contentLengthValue = response.headers && response.headers.get ? response.headers.get("content-length") : null;
|
|
9049
|
+
if (contentLengthValue != null && contentLengthValue !== "") {
|
|
9050
|
+
const contentLength = Number(contentLengthValue);
|
|
9051
|
+
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
|
|
9052
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_CONTENT_LENGTH", "업데이트 서버의 Content-Length가 올바르지 않습니다.");
|
|
9053
|
+
}
|
|
9054
|
+
if (contentLength > maxBytes) {
|
|
9055
|
+
throw updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 응답이 허용 크기(${maxBytes} bytes)를 초과합니다.`);
|
|
9056
|
+
}
|
|
9057
|
+
if (Number.isSafeInteger(expectedBytes) && contentLength !== expectedBytes) {
|
|
9058
|
+
throw updateTransferError("AGENTLAS_UPDATE_SIZE_MISMATCH", `다운로드 크기가 맞지 않습니다: expected=${expectedBytes} header=${contentLength}`);
|
|
9059
|
+
}
|
|
9060
|
+
}
|
|
9061
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
9062
|
+
throw updateTransferError("AGENTLAS_UPDATE_BODY_UNAVAILABLE", "업데이트 응답을 스트림으로 읽을 수 없습니다.");
|
|
9063
|
+
}
|
|
9064
|
+
|
|
9065
|
+
reader = response.body.getReader();
|
|
9066
|
+
let bytes = 0;
|
|
9067
|
+
armIdle();
|
|
9068
|
+
while (true) {
|
|
9069
|
+
const part = await Promise.race([reader.read(), terminal]);
|
|
9070
|
+
if (part.done) break;
|
|
9071
|
+
armIdle();
|
|
9072
|
+
const chunk = Buffer.from(part.value || []);
|
|
9073
|
+
bytes += chunk.length;
|
|
9074
|
+
if (bytes > maxBytes) {
|
|
9075
|
+
const error = updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 응답이 허용 크기(${maxBytes} bytes)를 초과했습니다.`);
|
|
9076
|
+
stop(error);
|
|
9077
|
+
throw error;
|
|
9078
|
+
}
|
|
9079
|
+
if (typeof options.onChunk === "function") await options.onChunk(chunk);
|
|
9080
|
+
}
|
|
9081
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
9082
|
+
idleTimer = null;
|
|
9083
|
+
return { response, bytes };
|
|
9084
|
+
} catch (error) {
|
|
9085
|
+
caughtError = terminalError || error;
|
|
9086
|
+
try { controller.abort(caughtError); } catch { controller.abort(); }
|
|
9087
|
+
throw caughtError;
|
|
9088
|
+
} finally {
|
|
9089
|
+
if (connectTimer) clearTimeout(connectTimer);
|
|
9090
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
9091
|
+
if (totalTimer) clearTimeout(totalTimer);
|
|
9092
|
+
if (upstreamSignal) upstreamSignal.removeEventListener?.("abort", onUpstreamAbort);
|
|
9093
|
+
if (reader && caughtError) {
|
|
9094
|
+
try { await reader.cancel(caughtError); } catch { /* ignore */ }
|
|
9095
|
+
}
|
|
7014
9096
|
}
|
|
7015
|
-
return 0;
|
|
7016
9097
|
}
|
|
7017
9098
|
|
|
7018
|
-
function
|
|
7019
|
-
|
|
7020
|
-
|
|
7021
|
-
|
|
9099
|
+
async function fetchUpdateMetadata(url, options = {}) {
|
|
9100
|
+
const safeUrl = parseSafeUpdateUrl(url, "업데이트 메타데이터 URL");
|
|
9101
|
+
const chunks = [];
|
|
9102
|
+
let bytes = 0;
|
|
9103
|
+
await consumeUpdateResponse(safeUrl, { headers: { accept: "application/json", "accept-encoding": "identity" }, signal: options.signal }, {
|
|
9104
|
+
...options,
|
|
9105
|
+
kind: "metadata",
|
|
9106
|
+
maxBytes: Number.isSafeInteger(options.maxBytes) ? options.maxBytes : UPDATE_METADATA_MAX_BYTES,
|
|
9107
|
+
onChunk(chunk) {
|
|
9108
|
+
bytes += chunk.length;
|
|
9109
|
+
chunks.push(chunk);
|
|
9110
|
+
},
|
|
9111
|
+
});
|
|
9112
|
+
let json;
|
|
9113
|
+
try {
|
|
9114
|
+
json = JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
|
|
9115
|
+
} catch (error) {
|
|
9116
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_METADATA", "업데이트 정보가 올바른 JSON이 아닙니다.", error);
|
|
9117
|
+
}
|
|
9118
|
+
if (!json || typeof json !== "object" || Array.isArray(json) || !parseSemVer(json.version)) {
|
|
9119
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_METADATA", "업데이트 정보 형식이 올바르지 않습니다.");
|
|
9120
|
+
}
|
|
9121
|
+
return { ...json, version: normalizeSemVer(json.version) };
|
|
7022
9122
|
}
|
|
7023
9123
|
|
|
7024
9124
|
async function fetchDesktopRelease(url) {
|
|
7025
9125
|
const controller = new AbortController();
|
|
7026
|
-
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
7027
9126
|
try {
|
|
7028
|
-
|
|
7029
|
-
if (!resp.ok) fail(`업데이트 정보를 가져오지 못했습니다: ${resp.status}`);
|
|
7030
|
-
const json = await resp.json();
|
|
7031
|
-
if (!json || typeof json !== "object" || !json.version) fail("업데이트 정보 형식이 올바르지 않습니다.");
|
|
7032
|
-
return json;
|
|
9127
|
+
return await fetchUpdateMetadata(url, { signal: controller.signal });
|
|
7033
9128
|
} catch (error) {
|
|
7034
|
-
const message =
|
|
9129
|
+
const message = String((error && error.message) || error);
|
|
7035
9130
|
fail(`업데이트 확인 실패: ${message}`);
|
|
7036
|
-
} finally {
|
|
7037
|
-
clearTimeout(timer);
|
|
7038
9131
|
}
|
|
7039
9132
|
}
|
|
7040
9133
|
|
|
@@ -7069,8 +9162,9 @@ async function cmdUpdateStandalone(flags) {
|
|
|
7069
9162
|
const resp = await fetch("https://registry.npmjs.org/agentlas/latest", { headers: { accept: "application/json" } });
|
|
7070
9163
|
if (resp.ok) latestVersion = String((await resp.json()).version || "");
|
|
7071
9164
|
} catch { /* offline 등 — 아래에서 안내 */ }
|
|
9165
|
+
const comparison = latestVersion ? compareSemVer(currentVersion, latestVersion) : null;
|
|
7072
9166
|
if (flags.json) {
|
|
7073
|
-
return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable:
|
|
9167
|
+
return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable: comparison == null ? null : comparison < 0, channel: "npm" }, null, 2));
|
|
7074
9168
|
}
|
|
7075
9169
|
out(`현재 버전: ${currentVersion}`);
|
|
7076
9170
|
if (!latestVersion) {
|
|
@@ -7079,7 +9173,9 @@ async function cmdUpdateStandalone(flags) {
|
|
|
7079
9173
|
return;
|
|
7080
9174
|
}
|
|
7081
9175
|
out(`최신 버전: ${latestVersion}`);
|
|
7082
|
-
if (
|
|
9176
|
+
if (comparison == null) {
|
|
9177
|
+
out("버전 형식을 비교하지 못했습니다. 수동 업데이트: npm i -g agentlas@latest");
|
|
9178
|
+
} else if (comparison < 0) {
|
|
7083
9179
|
out("업데이트: npm i -g agentlas@latest");
|
|
7084
9180
|
} else {
|
|
7085
9181
|
out("이미 최신 버전입니다.");
|
|
@@ -7096,7 +9192,9 @@ async function cmdUpdate(args) {
|
|
|
7096
9192
|
const release = await fetchDesktopRelease(flags.url);
|
|
7097
9193
|
const latestVersion = String(release.version || "");
|
|
7098
9194
|
const artifact = findCurrentArtifact(release);
|
|
7099
|
-
const
|
|
9195
|
+
const comparison = compareSemVer(currentVersion, latestVersion);
|
|
9196
|
+
if (comparison == null) fail(`현재/최신 버전이 SemVer 형식이 아닙니다: current=${currentVersion} latest=${latestVersion}`);
|
|
9197
|
+
const updateAvailable = comparison < 0;
|
|
7100
9198
|
const status = {
|
|
7101
9199
|
currentVersion,
|
|
7102
9200
|
latestVersion,
|
|
@@ -7153,17 +9251,80 @@ function sleep(ms) {
|
|
|
7153
9251
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7154
9252
|
}
|
|
7155
9253
|
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
9254
|
+
function validateDesktopUpdateArtifact(artifact, options = {}) {
|
|
9255
|
+
if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) {
|
|
9256
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_ARTIFACT", "업데이트 아티팩트 정보가 없습니다.");
|
|
9257
|
+
}
|
|
9258
|
+
const url = parseSafeUpdateUrl(artifact.url, "업데이트 아티팩트 URL");
|
|
9259
|
+
const sha256 = String(artifact.sha256 || "").trim().toLowerCase();
|
|
9260
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) {
|
|
9261
|
+
throw updateTransferError("AGENTLAS_UPDATE_MISSING_DIGEST", "안전한 자동 업데이트를 위해 64자리 SHA-256이 반드시 필요합니다.");
|
|
9262
|
+
}
|
|
9263
|
+
const sizeBytes = Number(artifact.sizeBytes);
|
|
9264
|
+
if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {
|
|
9265
|
+
throw updateTransferError("AGENTLAS_UPDATE_MISSING_SIZE", "안전한 자동 업데이트를 위해 정확한 sizeBytes가 반드시 필요합니다.");
|
|
9266
|
+
}
|
|
9267
|
+
const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0
|
|
9268
|
+
? options.maxBytes
|
|
9269
|
+
: updateDownloadMaxBytes(options.env || process.env);
|
|
9270
|
+
if (sizeBytes > maxBytes) {
|
|
9271
|
+
throw updateTransferError("AGENTLAS_UPDATE_TOO_LARGE", `업데이트 파일 크기(${sizeBytes} bytes)가 허용 한도(${maxBytes} bytes)를 초과합니다.`);
|
|
9272
|
+
}
|
|
9273
|
+
let fileName = artifact.fileName == null ? "" : String(artifact.fileName).trim();
|
|
9274
|
+
if (fileName) {
|
|
9275
|
+
if (fileName.length > 180 || /[\\/\0]/.test(fileName) || path.basename(fileName) !== fileName || !fileName.toLowerCase().endsWith(".dmg")) {
|
|
9276
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_FILENAME", "업데이트 파일 이름이 안전하지 않습니다.");
|
|
9277
|
+
}
|
|
9278
|
+
}
|
|
9279
|
+
return { ...artifact, url, sha256, sizeBytes, fileName };
|
|
9280
|
+
}
|
|
9281
|
+
|
|
9282
|
+
async function downloadUpdateFile(url, destination, artifact, options = {}) {
|
|
9283
|
+
const validated = validateDesktopUpdateArtifact({ ...artifact, url }, options);
|
|
9284
|
+
if (fs.existsSync(destination)) {
|
|
9285
|
+
throw updateTransferError("AGENTLAS_UPDATE_DESTINATION_EXISTS", `업데이트 다운로드 대상이 이미 존재합니다: ${destination}`);
|
|
9286
|
+
}
|
|
9287
|
+
const partialPath = options.partialPath || `${destination}.partial.${process.pid}.${crypto.randomBytes(6).toString("hex")}`;
|
|
9288
|
+
if (fs.existsSync(partialPath)) {
|
|
9289
|
+
throw updateTransferError("AGENTLAS_UPDATE_PARTIAL_EXISTS", `업데이트 임시 파일이 이미 존재합니다: ${partialPath}`);
|
|
7163
9290
|
}
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
9291
|
+
const hash = crypto.createHash("sha256");
|
|
9292
|
+
let fd = null;
|
|
9293
|
+
let actualBytes = 0;
|
|
9294
|
+
try {
|
|
9295
|
+
fd = fs.openSync(partialPath, "wx", 0o600);
|
|
9296
|
+
await consumeUpdateResponse(validated.url, {
|
|
9297
|
+
headers: { accept: "application/octet-stream", "accept-encoding": "identity" },
|
|
9298
|
+
signal: options.signal,
|
|
9299
|
+
}, {
|
|
9300
|
+
...options,
|
|
9301
|
+
kind: "download",
|
|
9302
|
+
maxBytes: Number.isSafeInteger(options.maxBytes) ? options.maxBytes : updateDownloadMaxBytes(options.env || process.env),
|
|
9303
|
+
expectedBytes: validated.sizeBytes,
|
|
9304
|
+
onChunk(chunk) {
|
|
9305
|
+
fs.writeSync(fd, chunk, 0, chunk.length);
|
|
9306
|
+
hash.update(chunk);
|
|
9307
|
+
actualBytes += chunk.length;
|
|
9308
|
+
},
|
|
9309
|
+
});
|
|
9310
|
+
fs.fsyncSync(fd);
|
|
9311
|
+
fs.closeSync(fd);
|
|
9312
|
+
fd = null;
|
|
9313
|
+
if (actualBytes !== validated.sizeBytes) {
|
|
9314
|
+
throw updateTransferError("AGENTLAS_UPDATE_SIZE_MISMATCH", `다운로드 크기가 맞지 않습니다: expected=${validated.sizeBytes} actual=${actualBytes}`);
|
|
9315
|
+
}
|
|
9316
|
+
const actualSha256 = hash.digest("hex");
|
|
9317
|
+
if (actualSha256 !== validated.sha256) {
|
|
9318
|
+
throw updateTransferError("AGENTLAS_UPDATE_DIGEST_MISMATCH", `다운로드 SHA-256이 맞지 않습니다: expected=${validated.sha256} actual=${actualSha256}`);
|
|
9319
|
+
}
|
|
9320
|
+
fs.renameSync(partialPath, destination);
|
|
9321
|
+
return { bytes: actualBytes, sha256: actualSha256, destination };
|
|
9322
|
+
} catch (error) {
|
|
9323
|
+
if (fd != null) {
|
|
9324
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
9325
|
+
}
|
|
9326
|
+
try { fs.rmSync(partialPath, { force: true }); } catch { /* ignore */ }
|
|
9327
|
+
throw error;
|
|
7167
9328
|
}
|
|
7168
9329
|
}
|
|
7169
9330
|
|
|
@@ -7180,10 +9341,199 @@ function macAppInstallPath() {
|
|
|
7180
9341
|
return "/Applications/Agentlas.app";
|
|
7181
9342
|
}
|
|
7182
9343
|
|
|
9344
|
+
async function verifyMacAppBundle(appPath, options = {}) {
|
|
9345
|
+
const runner = options.runCommand || runCommand;
|
|
9346
|
+
const commands = options.commands || {};
|
|
9347
|
+
if (!commands.codesign || !commands.spctl) {
|
|
9348
|
+
throw updateTransferError("AGENTLAS_UPDATE_VERIFY_TOOL_MISSING", "앱 서명 검증 도구가 지정되지 않았습니다.");
|
|
9349
|
+
}
|
|
9350
|
+
await runner(commands.codesign, ["--verify", "--deep", "--strict", "--verbose=2", appPath]);
|
|
9351
|
+
const detail = await runner(commands.codesign, ["-d", "--verbose=4", appPath], { capture: true });
|
|
9352
|
+
const signatureText = `${(detail && detail.stdout) || ""}\n${(detail && detail.stderr) || ""}`;
|
|
9353
|
+
const identifier = (signatureText.match(/^Identifier=(.+)$/m) || [])[1]?.trim() || "";
|
|
9354
|
+
const teamIdentifier = (signatureText.match(/^TeamIdentifier=(.+)$/m) || [])[1]?.trim() || "";
|
|
9355
|
+
if (identifier !== "com.agentlas.desktop") {
|
|
9356
|
+
throw updateTransferError("AGENTLAS_UPDATE_SIGNER_MISMATCH", `앱 번들 식별자가 올바르지 않습니다: ${identifier || "missing"}`);
|
|
9357
|
+
}
|
|
9358
|
+
if (!teamIdentifier || teamIdentifier.toLowerCase() === "not set") {
|
|
9359
|
+
throw updateTransferError("AGENTLAS_UPDATE_SIGNER_MISSING", "앱 서명에서 Apple TeamIdentifier를 확인하지 못했습니다.");
|
|
9360
|
+
}
|
|
9361
|
+
await runner(commands.spctl, ["-a", "-t", "exec", "-vv", appPath]);
|
|
9362
|
+
return { identifier, teamIdentifier };
|
|
9363
|
+
}
|
|
9364
|
+
|
|
9365
|
+
function assertSameMacSigningIdentity(expected, actual, phase) {
|
|
9366
|
+
if (!expected || !actual) return;
|
|
9367
|
+
if (expected.identifier !== actual.identifier || expected.teamIdentifier !== actual.teamIdentifier) {
|
|
9368
|
+
throw updateTransferError(
|
|
9369
|
+
"AGENTLAS_UPDATE_SIGNER_MISMATCH",
|
|
9370
|
+
`${phase} 앱의 서명 주체가 다릅니다: expected=${expected.identifier}/${expected.teamIdentifier} actual=${actual.identifier}/${actual.teamIdentifier}`,
|
|
9371
|
+
);
|
|
9372
|
+
}
|
|
9373
|
+
}
|
|
9374
|
+
|
|
9375
|
+
async function removeUpdatePathChecked(targetPath, options) {
|
|
9376
|
+
const fsImpl = options.fs || fs;
|
|
9377
|
+
if (!fsImpl.existsSync(targetPath)) return;
|
|
9378
|
+
try {
|
|
9379
|
+
await options.runCommand(options.commands.rm, ["-rf", targetPath]);
|
|
9380
|
+
} catch (error) {
|
|
9381
|
+
if (fsImpl.existsSync(targetPath)) throw error;
|
|
9382
|
+
}
|
|
9383
|
+
if (fsImpl.existsSync(targetPath)) {
|
|
9384
|
+
throw updateTransferError("AGENTLAS_UPDATE_REMOVE_FAILED", `업데이트 임시 경로를 제거하지 못했습니다: ${targetPath}`);
|
|
9385
|
+
}
|
|
9386
|
+
}
|
|
9387
|
+
|
|
9388
|
+
/**
|
|
9389
|
+
* 기존 앱을 같은 디렉터리의 backup으로 원자 이동한 뒤 staging 앱을 검증해 교체한다.
|
|
9390
|
+
* backup이 생긴 이후 어느 단계든 실패하면 원본을 다시 이동하고 서명까지 재검증한다.
|
|
9391
|
+
*/
|
|
9392
|
+
async function replaceMacAppBundle(options) {
|
|
9393
|
+
const rawPaths = [options.sourceApp, options.targetApp, options.backupPath, options.stagingPath];
|
|
9394
|
+
if (rawPaths.some((value) => typeof value !== "string" || !value.trim())) {
|
|
9395
|
+
throw updateTransferError("AGENTLAS_UPDATE_PATH_MISSING", "업데이트 source/target/backup/staging 경로가 모두 필요합니다.");
|
|
9396
|
+
}
|
|
9397
|
+
const sourceApp = path.resolve(options.sourceApp);
|
|
9398
|
+
const targetApp = path.resolve(options.targetApp);
|
|
9399
|
+
const backupPath = path.resolve(options.backupPath);
|
|
9400
|
+
const stagingPath = path.resolve(options.stagingPath);
|
|
9401
|
+
const runner = options.runCommand || runCommand;
|
|
9402
|
+
const fsImpl = options.fs || fs;
|
|
9403
|
+
const commands = options.commands || {};
|
|
9404
|
+
const verifyApp = options.verifyApp || ((appPath, context) => verifyMacAppBundle(appPath, {
|
|
9405
|
+
runCommand: runner,
|
|
9406
|
+
commands,
|
|
9407
|
+
context,
|
|
9408
|
+
}));
|
|
9409
|
+
if (!commands.mv || !commands.rm || !commands.ditto) {
|
|
9410
|
+
throw updateTransferError("AGENTLAS_UPDATE_INSTALL_TOOL_MISSING", "앱 교체 도구가 지정되지 않았습니다.");
|
|
9411
|
+
}
|
|
9412
|
+
if (!fsImpl.existsSync(sourceApp)) {
|
|
9413
|
+
throw updateTransferError("AGENTLAS_UPDATE_SOURCE_MISSING", `설치할 앱을 찾지 못했습니다: ${sourceApp}`);
|
|
9414
|
+
}
|
|
9415
|
+
if (!sourceApp.toLowerCase().endsWith(".app") || !targetApp.toLowerCase().endsWith(".app")) {
|
|
9416
|
+
throw updateTransferError("AGENTLAS_UPDATE_INVALID_APP_PATH", "업데이트 source와 target은 .app 번들이어야 합니다.");
|
|
9417
|
+
}
|
|
9418
|
+
if (new Set([sourceApp, targetApp, backupPath, stagingPath]).size !== 4) {
|
|
9419
|
+
throw updateTransferError("AGENTLAS_UPDATE_PATH_COLLISION", "업데이트 source/target/backup/staging 경로가 서로 달라야 합니다.");
|
|
9420
|
+
}
|
|
9421
|
+
if (path.dirname(backupPath) !== path.dirname(targetApp) || path.dirname(stagingPath) !== path.dirname(targetApp)) {
|
|
9422
|
+
throw updateTransferError("AGENTLAS_UPDATE_NONATOMIC_PATH", "backup과 staging은 대상 앱과 같은 디렉터리에 있어야 합니다.");
|
|
9423
|
+
}
|
|
9424
|
+
if (fsImpl.existsSync(backupPath) || fsImpl.existsSync(stagingPath)) {
|
|
9425
|
+
throw updateTransferError("AGENTLAS_UPDATE_PATH_EXISTS", "업데이트 backup 또는 staging 경로가 이미 존재합니다.");
|
|
9426
|
+
}
|
|
9427
|
+
|
|
9428
|
+
const hadOriginal = fsImpl.existsSync(targetApp);
|
|
9429
|
+
let sourceIdentity = null;
|
|
9430
|
+
let originalIdentity = null;
|
|
9431
|
+
try {
|
|
9432
|
+
sourceIdentity = await verifyApp(sourceApp, { phase: "source" });
|
|
9433
|
+
if (hadOriginal) {
|
|
9434
|
+
originalIdentity = await verifyApp(targetApp, { phase: "original" });
|
|
9435
|
+
assertSameMacSigningIdentity(originalIdentity, sourceIdentity, "새 릴리즈");
|
|
9436
|
+
await runner(commands.mv, [targetApp, backupPath]);
|
|
9437
|
+
if (fsImpl.existsSync(targetApp) || !fsImpl.existsSync(backupPath)) {
|
|
9438
|
+
throw updateTransferError("AGENTLAS_UPDATE_BACKUP_FAILED", "기존 앱 백업 이동을 확인하지 못했습니다.");
|
|
9439
|
+
}
|
|
9440
|
+
const backupIdentity = await verifyApp(backupPath, { phase: "backup" });
|
|
9441
|
+
assertSameMacSigningIdentity(originalIdentity, backupIdentity, "백업");
|
|
9442
|
+
}
|
|
9443
|
+
|
|
9444
|
+
await runner(commands.ditto, [sourceApp, stagingPath]);
|
|
9445
|
+
if (!fsImpl.existsSync(stagingPath)) {
|
|
9446
|
+
throw updateTransferError("AGENTLAS_UPDATE_STAGE_MISSING", "복사 후 staging 앱을 찾지 못했습니다.");
|
|
9447
|
+
}
|
|
9448
|
+
const stagingIdentity = await verifyApp(stagingPath, { phase: "staging" });
|
|
9449
|
+
assertSameMacSigningIdentity(sourceIdentity, stagingIdentity, "staging");
|
|
9450
|
+
await runner(commands.mv, [stagingPath, targetApp]);
|
|
9451
|
+
if (fsImpl.existsSync(stagingPath) || !fsImpl.existsSync(targetApp)) {
|
|
9452
|
+
throw updateTransferError("AGENTLAS_UPDATE_COMMIT_FAILED", "검증된 앱의 최종 이동을 확인하지 못했습니다.");
|
|
9453
|
+
}
|
|
9454
|
+
const installedIdentity = await verifyApp(targetApp, { phase: "installed" });
|
|
9455
|
+
assertSameMacSigningIdentity(sourceIdentity, installedIdentity, "설치된");
|
|
9456
|
+
|
|
9457
|
+
let backupRetained = false;
|
|
9458
|
+
if (hadOriginal && fsImpl.existsSync(backupPath)) {
|
|
9459
|
+
try {
|
|
9460
|
+
await removeUpdatePathChecked(backupPath, { fs: fsImpl, runCommand: runner, commands });
|
|
9461
|
+
} catch {
|
|
9462
|
+
backupRetained = fsImpl.existsSync(backupPath);
|
|
9463
|
+
}
|
|
9464
|
+
}
|
|
9465
|
+
return { hadOriginal, backupRetained, backupPath: backupRetained ? backupPath : null };
|
|
9466
|
+
} catch (originalError) {
|
|
9467
|
+
if (hadOriginal && fsImpl.existsSync(backupPath)) {
|
|
9468
|
+
let rollbackError = null;
|
|
9469
|
+
try {
|
|
9470
|
+
if (fsImpl.existsSync(stagingPath)) {
|
|
9471
|
+
try { await removeUpdatePathChecked(stagingPath, { fs: fsImpl, runCommand: runner, commands }); } catch { /* does not block original restore */ }
|
|
9472
|
+
}
|
|
9473
|
+
if (fsImpl.existsSync(targetApp)) {
|
|
9474
|
+
await removeUpdatePathChecked(targetApp, { fs: fsImpl, runCommand: runner, commands });
|
|
9475
|
+
}
|
|
9476
|
+
await runner(commands.mv, [backupPath, targetApp]);
|
|
9477
|
+
if (fsImpl.existsSync(backupPath) || !fsImpl.existsSync(targetApp)) {
|
|
9478
|
+
throw updateTransferError("AGENTLAS_UPDATE_RESTORE_MOVE_FAILED", "백업 앱의 원위치 복구를 확인하지 못했습니다.");
|
|
9479
|
+
}
|
|
9480
|
+
const restoredIdentity = await verifyApp(targetApp, { phase: "restored" });
|
|
9481
|
+
assertSameMacSigningIdentity(originalIdentity, restoredIdentity, "복구된");
|
|
9482
|
+
} catch (error) {
|
|
9483
|
+
rollbackError = error;
|
|
9484
|
+
}
|
|
9485
|
+
if (rollbackError) {
|
|
9486
|
+
const critical = updateTransferError(
|
|
9487
|
+
"AGENTLAS_UPDATE_ROLLBACK_FAILED",
|
|
9488
|
+
`앱 교체 실패 후 원본 복구를 완료하지 못했습니다. target=${targetApp} backup=${backupPath}: ${rollbackError.message || rollbackError}`,
|
|
9489
|
+
originalError,
|
|
9490
|
+
);
|
|
9491
|
+
critical.rollbackError = rollbackError;
|
|
9492
|
+
critical.backupPath = fsImpl.existsSync(backupPath) ? backupPath : null;
|
|
9493
|
+
critical.targetPath = fsImpl.existsSync(targetApp) ? targetApp : null;
|
|
9494
|
+
throw critical;
|
|
9495
|
+
}
|
|
9496
|
+
const rolledBack = updateTransferError(
|
|
9497
|
+
"AGENTLAS_UPDATE_REPLACEMENT_FAILED_ROLLED_BACK",
|
|
9498
|
+
`앱 교체에 실패했지만 기존 앱을 복구하고 서명을 확인했습니다: ${originalError.message || originalError}`,
|
|
9499
|
+
originalError,
|
|
9500
|
+
);
|
|
9501
|
+
rolledBack.restoredPath = targetApp;
|
|
9502
|
+
throw rolledBack;
|
|
9503
|
+
}
|
|
9504
|
+
|
|
9505
|
+
try {
|
|
9506
|
+
if (fsImpl.existsSync(stagingPath)) {
|
|
9507
|
+
await removeUpdatePathChecked(stagingPath, { fs: fsImpl, runCommand: runner, commands });
|
|
9508
|
+
}
|
|
9509
|
+
if (!hadOriginal && fsImpl.existsSync(targetApp)) {
|
|
9510
|
+
await removeUpdatePathChecked(targetApp, { fs: fsImpl, runCommand: runner, commands });
|
|
9511
|
+
}
|
|
9512
|
+
} catch (cleanupError) {
|
|
9513
|
+
const cleanupFailure = updateTransferError(
|
|
9514
|
+
"AGENTLAS_UPDATE_CLEANUP_FAILED",
|
|
9515
|
+
`앱 교체 실패 후 임시 앱을 제거하지 못했습니다: ${cleanupError.message || cleanupError}`,
|
|
9516
|
+
originalError,
|
|
9517
|
+
);
|
|
9518
|
+
cleanupFailure.cleanupError = cleanupError;
|
|
9519
|
+
throw cleanupFailure;
|
|
9520
|
+
}
|
|
9521
|
+
if (hadOriginal && !fsImpl.existsSync(targetApp)) {
|
|
9522
|
+
throw updateTransferError(
|
|
9523
|
+
"AGENTLAS_UPDATE_ROLLBACK_FAILED",
|
|
9524
|
+
`앱 교체 실패 후 기존 앱과 백업을 모두 찾지 못했습니다. target=${targetApp} backup=${backupPath}`,
|
|
9525
|
+
originalError,
|
|
9526
|
+
);
|
|
9527
|
+
}
|
|
9528
|
+
throw originalError;
|
|
9529
|
+
}
|
|
9530
|
+
}
|
|
9531
|
+
|
|
7183
9532
|
async function installMacDesktopUpdate(release, artifact, flags) {
|
|
7184
9533
|
const hdiutil = requirePath("/usr/bin/hdiutil", "hdiutil");
|
|
7185
9534
|
const xcrun = requirePath("/usr/bin/xcrun", "xcrun");
|
|
7186
9535
|
const spctl = requirePath("/usr/sbin/spctl", "spctl");
|
|
9536
|
+
const codesign = requirePath("/usr/bin/codesign", "codesign");
|
|
7187
9537
|
const osascript = requirePath("/usr/bin/osascript", "osascript");
|
|
7188
9538
|
const ditto = requirePath("/usr/bin/ditto", "ditto");
|
|
7189
9539
|
const plistBuddy = requirePath("/usr/libexec/PlistBuddy", "PlistBuddy");
|
|
@@ -7192,16 +9542,21 @@ async function installMacDesktopUpdate(release, artifact, flags) {
|
|
|
7192
9542
|
const open = requirePath("/usr/bin/open", "open");
|
|
7193
9543
|
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
7194
9544
|
|
|
9545
|
+
const validatedArtifact = validateDesktopUpdateArtifact(artifact);
|
|
7195
9546
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-update."));
|
|
7196
|
-
const fileName =
|
|
9547
|
+
const fileName = validatedArtifact.fileName || `Agentlas-${macReleaseArch() || "mac"}.dmg`;
|
|
7197
9548
|
const dmgPath = path.join(tmpDir, fileName);
|
|
7198
9549
|
let mountPoint = "";
|
|
7199
|
-
let backupPath = "";
|
|
7200
9550
|
const targetApp = macAppInstallPath();
|
|
9551
|
+
const transactionId = `${Date.now()}.${process.pid}.${crypto.randomBytes(5).toString("hex")}`;
|
|
9552
|
+
const targetDir = path.dirname(targetApp);
|
|
9553
|
+
const targetName = path.basename(targetApp, path.extname(targetApp));
|
|
9554
|
+
const backupPath = path.join(targetDir, `.${targetName}.backup.${transactionId}.app`);
|
|
9555
|
+
const stagingPath = path.join(targetDir, `.${targetName}.installing.${transactionId}.app`);
|
|
7201
9556
|
|
|
7202
9557
|
try {
|
|
7203
9558
|
out(`다운로드: ${fileName}`);
|
|
7204
|
-
await downloadUpdateFile(
|
|
9559
|
+
await downloadUpdateFile(validatedArtifact.url, dmgPath, validatedArtifact);
|
|
7205
9560
|
out("검증: DMG, notarization, Gatekeeper");
|
|
7206
9561
|
await runCommand(hdiutil, ["verify", dmgPath]);
|
|
7207
9562
|
await runCommand(xcrun, ["stapler", "validate", dmgPath]);
|
|
@@ -7210,34 +9565,29 @@ async function installMacDesktopUpdate(release, artifact, flags) {
|
|
|
7210
9565
|
const mount = await runCommand(hdiutil, ["attach", "-nobrowse", "-readonly", dmgPath], { capture: true });
|
|
7211
9566
|
mountPoint = parseHdiutilMountPoint(mount.stdout);
|
|
7212
9567
|
const sourceApp = mountPoint ? path.join(mountPoint, "Agentlas.app") : "";
|
|
7213
|
-
if (!sourceApp || !fs.existsSync(sourceApp))
|
|
9568
|
+
if (!sourceApp || !fs.existsSync(sourceApp)) {
|
|
9569
|
+
throw updateTransferError("AGENTLAS_UPDATE_APP_MISSING", "DMG 안에서 Agentlas.app을 찾지 못했습니다.");
|
|
9570
|
+
}
|
|
7214
9571
|
|
|
7215
9572
|
const installedVersion = await runCommand(plistBuddy, ["-c", "Print :CFBundleShortVersionString", path.join(sourceApp, "Contents", "Info.plist")], { capture: true });
|
|
7216
9573
|
const appVersion = installedVersion.stdout.trim();
|
|
7217
|
-
if (appVersion !== String(release.version))
|
|
7218
|
-
|
|
9574
|
+
if (appVersion !== String(release.version)) {
|
|
9575
|
+
throw updateTransferError("AGENTLAS_UPDATE_VERSION_MISMATCH", `앱 버전이 릴리즈와 다릅니다: release=${release.version} app=${appVersion}`);
|
|
9576
|
+
}
|
|
7219
9577
|
|
|
7220
9578
|
out("설치: 기존 Agentlas 종료 후 앱 교체");
|
|
7221
9579
|
await runCommand(osascript, ["-e", 'tell application "Agentlas" to quit'], { capture: true, allowFailure: true });
|
|
7222
9580
|
await sleep(2_000);
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
9581
|
+
const replacement = await replaceMacAppBundle({
|
|
9582
|
+
sourceApp,
|
|
9583
|
+
targetApp,
|
|
9584
|
+
backupPath,
|
|
9585
|
+
stagingPath,
|
|
9586
|
+
runCommand,
|
|
9587
|
+
commands: { codesign, spctl, ditto, mv, rm },
|
|
9588
|
+
});
|
|
9589
|
+
if (replacement.backupRetained) out(`주의: 검증된 새 앱은 설치됐지만 이전 앱 백업을 지우지 못했습니다: ${replacement.backupPath}`);
|
|
7228
9590
|
if (fs.existsSync(lsregister)) await runCommand(lsregister, ["-f", targetApp], { allowFailure: true });
|
|
7229
|
-
|
|
7230
|
-
try {
|
|
7231
|
-
await runCommand(spctl, ["-a", "-vv", targetApp]);
|
|
7232
|
-
} catch (error) {
|
|
7233
|
-
if (backupPath && fs.existsSync(backupPath)) {
|
|
7234
|
-
await runCommand(rm, ["-rf", targetApp], { allowFailure: true });
|
|
7235
|
-
await runCommand(mv, [backupPath, targetApp], { allowFailure: true });
|
|
7236
|
-
}
|
|
7237
|
-
throw error;
|
|
7238
|
-
}
|
|
7239
|
-
|
|
7240
|
-
if (backupPath && fs.existsSync(backupPath)) await runCommand(rm, ["-rf", backupPath], { allowFailure: true });
|
|
7241
9591
|
if (flags.launch) await runCommand(open, ["-a", "Agentlas"], { allowFailure: true });
|
|
7242
9592
|
out(`Agentlas ${release.version} 설치 완료.`);
|
|
7243
9593
|
} finally {
|
|
@@ -7581,7 +9931,8 @@ function cmdHelp() {
|
|
|
7581
9931
|
" search \"<what you need>\" discover agents in the Hub + local (hep-search)",
|
|
7582
9932
|
" install <slug> install an agent from the Hub (hep-cloud)",
|
|
7583
9933
|
" build \"<request>\" build/repair/package an agent or team (hep-build)",
|
|
7584
|
-
" upload <path>
|
|
9934
|
+
" upload <path> save owner-private in Agent Cloud (default) (hep-upload)",
|
|
9935
|
+
" --visibility marketplace explicit compatibility flag: publish to Hub",
|
|
7585
9936
|
" connect [<sub>] wire Telegram / platforms to an agent team (hep-connect)",
|
|
7586
9937
|
" import <path> import a local agent/team folder",
|
|
7587
9938
|
" list installed agents/companies + active runtime",
|
|
@@ -7614,7 +9965,7 @@ function cmdHelp() {
|
|
|
7614
9965
|
hdr("ADVANCED"),
|
|
7615
9966
|
" hep <sub…> full Hephaestus passthrough (wizard·security·cards·ao·plugins·meta-agent…)",
|
|
7616
9967
|
" netadmin <sub> local network admin: init|status|reindex|bench|add-source",
|
|
7617
|
-
" cloud <sub>
|
|
9968
|
+
" cloud <sub> cloud assets: save|publish|package|list|restore|field-test",
|
|
7618
9969
|
" cd <agent> print the agent folder — cd \"$(agentlas cd seo)\" && claude",
|
|
7619
9970
|
" oberon <sub> AI film render (scaffold|render|list)",
|
|
7620
9971
|
"",
|
|
@@ -7666,6 +10017,10 @@ async function main() {
|
|
|
7666
10017
|
|
|
7667
10018
|
const db = openDb();
|
|
7668
10019
|
|
|
10020
|
+
// Finish or compensate any Cloud install interrupted between the durable
|
|
10021
|
+
// filesystem swap and the SQLite transaction before normal agent resolution.
|
|
10022
|
+
recoverCloudInstallJournalsCli(db);
|
|
10023
|
+
|
|
7669
10024
|
// Agentlas 아키텍처 빌트인 에이전트를 보장(앱과 동일, 멱등·버전 게이팅). 스키마가 준비됐을 때만.
|
|
7670
10025
|
try { seedBuiltins(db); } catch { /* best-effort */ }
|
|
7671
10026
|
|
|
@@ -7724,12 +10079,14 @@ async function main() {
|
|
|
7724
10079
|
case "search": // hep-search — 에이전트 디렉터리 발견 (Hub + 로컬)
|
|
7725
10080
|
if (!rest[1]) return fail('usage: agentlas search "<찾는 일>" [--limit 10]');
|
|
7726
10081
|
return parity().cloudSearch(db, rest.slice(1));
|
|
7727
|
-
case "install": //
|
|
10082
|
+
case "install": // public Hub package install — slug로 에이전트 설치
|
|
7728
10083
|
if (!rest[1]) return fail('usage: agentlas install <slug> (먼저 agentlas search "할 일" 로 찾으세요)');
|
|
7729
10084
|
return cmdCloudInstall(db, rest[1]);
|
|
7730
|
-
case "upload": //
|
|
7731
|
-
if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로>
|
|
7732
|
-
|
|
10085
|
+
case "upload": { // 기본은 owner-private Agent Cloud, public Hub는 명시 flag로만.
|
|
10086
|
+
if (!rest[1]) return fail("usage: agentlas upload <에이전트 폴더 경로> [--visibility marketplace]");
|
|
10087
|
+
const uploadArgs = rest.slice(1);
|
|
10088
|
+
return cmdCloud(db, [cloudActionForTopLevelUpload(uploadArgs), ...uploadArgs], runtimeOverride);
|
|
10089
|
+
}
|
|
7733
10090
|
case "connect": // hep-connect — Telegram 등 플랫폼 연결
|
|
7734
10091
|
return parity().cmdHep(db, ["hep-connect", ...rest.slice(1)]);
|
|
7735
10092
|
case "browser": // hep-browser — 실제 브라우저 실행 하드포인트
|
|
@@ -7791,4 +10148,51 @@ async function main() {
|
|
|
7791
10148
|
}
|
|
7792
10149
|
}
|
|
7793
10150
|
|
|
7794
|
-
|
|
10151
|
+
// 런처가 스폰하는 실행 파일일 때만 CLI main을 돌린다. 회귀 테스트/라이브러리 require는 종료하지 않는다.
|
|
10152
|
+
if (require.main === module) {
|
|
10153
|
+
main().catch((e) => fail(String(e && e.stack ? e.stack : e)));
|
|
10154
|
+
}
|
|
10155
|
+
|
|
10156
|
+
module.exports = {
|
|
10157
|
+
runApi,
|
|
10158
|
+
normalizeCustomApiBaseUrl,
|
|
10159
|
+
readCustomApiBaseUrl,
|
|
10160
|
+
parseDotEnvCli,
|
|
10161
|
+
isProtectedChildEnvKeyCli,
|
|
10162
|
+
mergeChildEnvValuesCli,
|
|
10163
|
+
resolveCredentialSourcePath,
|
|
10164
|
+
upsertEnvLine,
|
|
10165
|
+
fetchHubCli,
|
|
10166
|
+
hubTimeoutConfig,
|
|
10167
|
+
compareSemVer,
|
|
10168
|
+
parseSemVer,
|
|
10169
|
+
updateTimeoutConfig,
|
|
10170
|
+
fetchUpdateMetadata,
|
|
10171
|
+
validateDesktopUpdateArtifact,
|
|
10172
|
+
downloadUpdateFile,
|
|
10173
|
+
verifyMacAppBundle,
|
|
10174
|
+
replaceMacAppBundle,
|
|
10175
|
+
captureRuntime,
|
|
10176
|
+
buildArgs,
|
|
10177
|
+
captureOutputLimit,
|
|
10178
|
+
materializeCloudListingCli,
|
|
10179
|
+
recoverCloudInstallJournalCli,
|
|
10180
|
+
recoverCloudInstallJournalsCli,
|
|
10181
|
+
persistCloudListingCli,
|
|
10182
|
+
cloudSystemPromptFromPackageCli,
|
|
10183
|
+
agentSystemPromptCli,
|
|
10184
|
+
listOwnedCloudAgentsCli,
|
|
10185
|
+
restoreOwnedCloudAgentCli,
|
|
10186
|
+
deleteCloudAgentCli,
|
|
10187
|
+
readCloudAssetStateCli,
|
|
10188
|
+
normalizeCloudAssetDescriptorCli,
|
|
10189
|
+
packageCloudAgentCli,
|
|
10190
|
+
cloudVisibilityForAction,
|
|
10191
|
+
cloudActionForTopLevelUpload,
|
|
10192
|
+
cloudHashPackage,
|
|
10193
|
+
cloudPackageHashVersion,
|
|
10194
|
+
cloudPortablePathConflict,
|
|
10195
|
+
cloudPortableExecutableForFile,
|
|
10196
|
+
DEFAULT_API_MODEL,
|
|
10197
|
+
ANTHROPIC_COMPAT_API,
|
|
10198
|
+
};
|