agentlas 0.4.0 → 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 +112 -23
- package/bin/agentlas.cjs +55 -8
- package/engine/agentlas-api-agent.cjs +1 -1
- package/engine/agentlas-banner.cjs +66 -51
- 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 +65 -14
- package/engine/agentlas-i18n.cjs +132 -12
- package/engine/agentlas-input.cjs +123 -19
- package/engine/agentlas-native-host.cjs +381 -83
- package/engine/agentlas-parity.cjs +373 -53
- package/engine/agentlas-permissions.cjs +90 -0
- package/engine/agentlas-repl.cjs +149 -47
- package/engine/agentlas-tasks.cjs +111 -0
- package/engine/agentlas-tools.cjs +174 -12
- package/engine/agentlas-ui.cjs +349 -24
- package/engine/agentlas.cjs +3074 -379
- package/engine/architecture.data.json +5 -1
- 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 +33 -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}.`,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
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(),
|
|
970
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
|
+
}
|
|
971
1493
|
}
|
|
972
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
|
-
} else {
|
|
1267
|
-
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,?,?,?)")
|
|
1268
|
-
.run(id, slug, listing.name || slug, listing.nameEn || listing.name || slug, listing.tagline || "", listing.taglineEn || listing.tagline || "", listing.systemPrompt || "", mcpServers, envReqs, listing.trustGrade || "unknown", now, listing.tone || "blue");
|
|
2268
|
+
let installedAt = now;
|
|
2269
|
+
if (existing && String(existing.installed_at || "") === installedAt) {
|
|
2270
|
+
installedAt = new Date(Date.now() + 1).toISOString();
|
|
1269
2271
|
}
|
|
1270
|
-
const
|
|
1271
|
-
|
|
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;
|
|
2319
|
+
}
|
|
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
|
+
});
|
|
2455
|
+
}
|
|
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
|
+
});
|
|
1290
2477
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
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,55 +2653,463 @@ 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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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;
|
|
1338
2727
|
}
|
|
1339
|
-
function
|
|
1340
|
-
|
|
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);
|
|
2756
|
+
const explicit = stringFirstCli(
|
|
2757
|
+
manifest.agentlas?.displayName,
|
|
2758
|
+
manifest.agentlas?.name,
|
|
2759
|
+
manifest.manifest?.name,
|
|
2760
|
+
manifest.agentCard?.name,
|
|
2761
|
+
manifest.routingCard?.name,
|
|
2762
|
+
);
|
|
2763
|
+
if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 80);
|
|
2764
|
+
const text = cloudReadFirst(snapshot, ["agent.md", "AGENT.md", "README.md", "CLAUDE.md", "AGENTS.md"], 2000);
|
|
2765
|
+
const heading = text.match(/^#\s+(.+)$/m);
|
|
2766
|
+
return (heading ? heading[1] : fallbackName).replace(/\s+/g, " ").trim().slice(0, 80);
|
|
2767
|
+
}
|
|
2768
|
+
function cloudReadTagline(snapshot) {
|
|
2769
|
+
const manifest = cloudReadPackageJson(snapshot);
|
|
2770
|
+
const explicit = stringFirstCli(
|
|
2771
|
+
manifest.agentlas?.summary,
|
|
2772
|
+
manifest.agentlas?.description,
|
|
2773
|
+
manifest.manifest?.description,
|
|
2774
|
+
manifest.agentCard?.summary,
|
|
2775
|
+
manifest.routingCard?.summary,
|
|
2776
|
+
);
|
|
2777
|
+
if (explicit) return explicit.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
2778
|
+
const text = cloudReadFirst(snapshot, ["README.md", "agent.md", "AGENT.md"], 3000);
|
|
1341
2779
|
for (const line of text.split(/\r?\n/)) {
|
|
1342
2780
|
const t = line.trim();
|
|
1343
2781
|
if (t && !t.startsWith("#") && !t.startsWith(">")) return t.slice(0, 160);
|
|
1344
2782
|
}
|
|
1345
2783
|
return "Portable Agentlas cloud agent package.";
|
|
1346
2784
|
}
|
|
1347
|
-
function
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
2785
|
+
function cloudReadStableSlug(snapshot) {
|
|
2786
|
+
const manifest = cloudReadPackageJson(snapshot);
|
|
2787
|
+
return stringFirstCli(
|
|
2788
|
+
manifest.agentlas?.slug,
|
|
2789
|
+
manifest.agentlas?.id,
|
|
2790
|
+
manifest.manifest?.package,
|
|
2791
|
+
manifest.manifest?.slug,
|
|
2792
|
+
manifest.agentCard?.slug,
|
|
2793
|
+
manifest.agentCard?.id,
|
|
2794
|
+
manifest.routingCard?.agent_card_ref?.slug,
|
|
2795
|
+
);
|
|
2796
|
+
}
|
|
2797
|
+
function cloudReadPackageJson(snapshot) {
|
|
2798
|
+
return {
|
|
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"),
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
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();
|
|
1354
2814
|
}
|
|
1355
2815
|
return "";
|
|
1356
2816
|
}
|
|
1357
|
-
function
|
|
1358
|
-
for (const name of
|
|
1359
|
-
|
|
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);
|
|
1360
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";
|
|
1361
2831
|
return "agent";
|
|
1362
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
|
+
}
|
|
1363
2842
|
function cloudPackageDir(slug) {
|
|
1364
2843
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1365
2844
|
return path.join(userDataDir(), "cloud-agent-packages", `${slug}-${stamp}`);
|
|
1366
2845
|
}
|
|
1367
|
-
function
|
|
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}`);
|
|
1368
2854
|
const h = crypto.createHash("sha256");
|
|
1369
|
-
// 서버
|
|
2855
|
+
// 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
|
|
1370
2856
|
// 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
|
|
1371
2857
|
// 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
|
|
1372
|
-
for (const file of [...files].sort(
|
|
2858
|
+
for (const file of [...files].sort(cloudCodePointPathOrder)) {
|
|
1373
2859
|
h.update(file.path);
|
|
1374
2860
|
h.update("\0");
|
|
1375
2861
|
h.update(file.sha256);
|
|
1376
2862
|
h.update("\0");
|
|
2863
|
+
if (hashVersion === CLOUD_PACKAGE_HASH_V2) {
|
|
2864
|
+
h.update(file.executable ? "x" : "-");
|
|
2865
|
+
h.update("\0");
|
|
2866
|
+
}
|
|
1377
2867
|
}
|
|
1378
2868
|
return h.digest("hex");
|
|
1379
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
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
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");
|
|
3055
|
+
}
|
|
3056
|
+
return null;
|
|
3057
|
+
}
|
|
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 };
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
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;
|
|
3077
|
+
}
|
|
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
|
+
}
|
|
3104
|
+
}
|
|
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;
|
|
3112
|
+
}
|
|
1380
3113
|
function cloudSecuritySummary(findings) {
|
|
1381
3114
|
const blockerCount = findings.filter((f) => f.severity === "blocker").length;
|
|
1382
3115
|
const highCount = findings.filter((f) => f.severity === "high").length;
|
|
@@ -1413,7 +3146,7 @@ function loadArch() {
|
|
|
1413
3146
|
try {
|
|
1414
3147
|
_arch = require("./architecture.data.json");
|
|
1415
3148
|
} catch {
|
|
1416
|
-
_arch = { version: "0", agents: [], emitterBlock: "", eventsHeading: "## Memory Events", memoryDir: ".agentlas", soulFile: "project-soul-memory.md", sitemapFile: "sitemap.json", logFile: "memory-log.jsonl", kinds: [], scopes: [] };
|
|
3149
|
+
_arch = { version: "0", agents: [], emitterBlock: "", eventsHeading: "## Memory Events", memoryDir: ".agentlas", soulFile: "project-soul-memory.md", sitemapFile: "sitemap.json", logFile: "memory-log.jsonl", careerGraphConfigFile: "career-graph.json", careerGraphSourceManifestFile: "career-graph-sources.json", careerGraphInboxDir: "career-graph-inbox", careerGraphDbFile: "career-graph.sqlite", kinds: [], scopes: [] };
|
|
1417
3150
|
}
|
|
1418
3151
|
return _arch;
|
|
1419
3152
|
}
|
|
@@ -1731,6 +3464,10 @@ function ensureProjectMemoryCli(projectPath, projectName) {
|
|
|
1731
3464
|
const ontologySourceManifestFile = arch.ontologySourceManifestFile || "ontology-sources.json";
|
|
1732
3465
|
const ontologyInboxDir = arch.ontologyInboxDir || "ontology-inbox";
|
|
1733
3466
|
const ontologyDbFile = arch.ontologyDbFile || "ontology-runtime.sqlite";
|
|
3467
|
+
const careerGraphConfigFile = arch.careerGraphConfigFile || "career-graph.json";
|
|
3468
|
+
const careerGraphSourceManifestFile = arch.careerGraphSourceManifestFile || "career-graph-sources.json";
|
|
3469
|
+
const careerGraphInboxDir = arch.careerGraphInboxDir || "career-graph-inbox";
|
|
3470
|
+
const careerGraphDbFile = arch.careerGraphDbFile || "career-graph.sqlite";
|
|
1734
3471
|
const superOntologyContractFile = arch.superOntologyContractFile || "super-ontology-contract.json";
|
|
1735
3472
|
const superOntologyOpenWorldCoverageFile =
|
|
1736
3473
|
arch.superOntologyOpenWorldCoverageFile || "super-ontology-open-world-coverage.json";
|
|
@@ -1876,6 +3613,38 @@ function ensureProjectMemoryCli(projectPath, projectName) {
|
|
|
1876
3613
|
sources: [],
|
|
1877
3614
|
}, null, 2), "utf8");
|
|
1878
3615
|
}
|
|
3616
|
+
const careerGraphInbox = path.join(dir, careerGraphInboxDir);
|
|
3617
|
+
if (!fs.existsSync(careerGraphInbox)) fs.mkdirSync(careerGraphInbox, { recursive: true });
|
|
3618
|
+
const careerGraphConfig = path.join(dir, careerGraphConfigFile);
|
|
3619
|
+
if (!fs.existsSync(careerGraphConfig)) {
|
|
3620
|
+
fs.writeFileSync(careerGraphConfig, JSON.stringify({
|
|
3621
|
+
schemaVersion: "1.0",
|
|
3622
|
+
kind: "agentlas-career-graph",
|
|
3623
|
+
state: "active",
|
|
3624
|
+
model: "ledger_first_derived_index",
|
|
3625
|
+
projectRoot: projectPath,
|
|
3626
|
+
projectName: name,
|
|
3627
|
+
dbPath: path.join(dir, careerGraphDbFile),
|
|
3628
|
+
inboxPath: careerGraphInbox,
|
|
3629
|
+
sourceManifest: path.join(dir, careerGraphSourceManifestFile),
|
|
3630
|
+
canonicalSourcePolicy: {
|
|
3631
|
+
sourceOfTruth: "markdown_jsonl_json",
|
|
3632
|
+
graphIsRebuildable: true,
|
|
3633
|
+
fallbackWhenStale: "read_canonical_files",
|
|
3634
|
+
neverScanHomeDirectory: true,
|
|
3635
|
+
neverScanSiblingProjects: true,
|
|
3636
|
+
},
|
|
3637
|
+
}, null, 2), "utf8");
|
|
3638
|
+
}
|
|
3639
|
+
const careerGraphSources = path.join(dir, careerGraphSourceManifestFile);
|
|
3640
|
+
if (!fs.existsSync(careerGraphSources)) {
|
|
3641
|
+
fs.writeFileSync(careerGraphSources, JSON.stringify({
|
|
3642
|
+
schemaVersion: "1.0",
|
|
3643
|
+
kind: "agentlas-career-graph-source-manifest",
|
|
3644
|
+
projectRoot: projectPath,
|
|
3645
|
+
sources: [],
|
|
3646
|
+
}, null, 2), "utf8");
|
|
3647
|
+
}
|
|
1879
3648
|
for (const fileName of [skillTrialsFile, curatorDecisionsFile]) {
|
|
1880
3649
|
const filePath = path.join(dir, fileName);
|
|
1881
3650
|
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf8");
|
|
@@ -5416,7 +7185,172 @@ function registerOntologySourceCli(paths, source, kind, scope, cwd) {
|
|
|
5416
7185
|
manifest.sources = nextSources;
|
|
5417
7186
|
writeJsonSafeCli(paths.sourceManifestPath, manifest);
|
|
5418
7187
|
return [
|
|
5419
|
-
`Registered ontology source: ${sourcePath}`,
|
|
7188
|
+
`Registered ontology source: ${sourcePath}`,
|
|
7189
|
+
` kind: ${kind}`,
|
|
7190
|
+
` scope: ${scope}`,
|
|
7191
|
+
" copy: no",
|
|
7192
|
+
" scan: only this registered folder, not home/sibling projects",
|
|
7193
|
+
];
|
|
7194
|
+
}
|
|
7195
|
+
|
|
7196
|
+
function runOntologyCli(args, opts) {
|
|
7197
|
+
opts = opts || {};
|
|
7198
|
+
const cwd = path.resolve(opts.cwd || process.cwd());
|
|
7199
|
+
const projectPath = path.resolve(opts.projectPath || cwd);
|
|
7200
|
+
const normalizedArgs = Array.isArray(args) ? args : [];
|
|
7201
|
+
const sub = normalizedArgs[0] || "status";
|
|
7202
|
+
const paths = ensureOntologyCli(projectPath);
|
|
7203
|
+
if (sub === "status" || sub === "list") {
|
|
7204
|
+
return formatOntologyStatusCli(paths);
|
|
7205
|
+
}
|
|
7206
|
+
if (sub === "open") {
|
|
7207
|
+
if (!opts.noOpen) openLocalPathCli(paths.inboxPath);
|
|
7208
|
+
return [`Opened ontology inbox: ${paths.inboxPath}`];
|
|
7209
|
+
}
|
|
7210
|
+
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
7211
|
+
return ontologyUsageLinesCli();
|
|
7212
|
+
}
|
|
7213
|
+
if (sub === "add") {
|
|
7214
|
+
const flags = parseCloudFlags(normalizedArgs.slice(1));
|
|
7215
|
+
const source = flags._[0];
|
|
7216
|
+
const kind = inferOntologyKindCli(flags.kind || flags._[1], normalizedArgs.join(" "));
|
|
7217
|
+
const scope = inferOntologyScopeCli(flags.scope || flags._[2], normalizedArgs.join(" "), kind);
|
|
7218
|
+
return registerOntologySourceCli(paths, source, kind, scope, cwd);
|
|
7219
|
+
}
|
|
7220
|
+
if (["company", "personal", "project"].includes(String(sub).toLowerCase())) {
|
|
7221
|
+
const flags = parseCloudFlags(normalizedArgs.slice(1));
|
|
7222
|
+
const kind = inferOntologyKindCli(sub, normalizedArgs.join(" "));
|
|
7223
|
+
const scope = inferOntologyScopeCli(flags.scope || flags._[1], normalizedArgs.join(" "), kind);
|
|
7224
|
+
return registerOntologySourceCli(paths, flags._[0], kind, scope, cwd);
|
|
7225
|
+
}
|
|
7226
|
+
if (isOntologyPathishCli(sub, cwd, true)) {
|
|
7227
|
+
return registerOntologySourceCli(paths, sub, inferOntologyKindCli(null, normalizedArgs.join(" ")), inferOntologyScopeCli(null, normalizedArgs.join(" "), "project"), cwd);
|
|
7228
|
+
}
|
|
7229
|
+
return runOntologyCli(parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd), opts);
|
|
7230
|
+
}
|
|
7231
|
+
|
|
7232
|
+
function runOntologyNaturalCli(text, opts) {
|
|
7233
|
+
const cwd = path.resolve((opts && opts.cwd) || process.cwd());
|
|
7234
|
+
return runOntologyCli(parseOntologyNaturalArgsCli(text, cwd), { ...(opts || {}), cwd });
|
|
7235
|
+
}
|
|
7236
|
+
|
|
7237
|
+
function careerGraphPathsForCli(projectPath) {
|
|
7238
|
+
const arch = loadArch();
|
|
7239
|
+
const root = path.resolve(projectPath || process.cwd());
|
|
7240
|
+
const memoryDir = path.join(root, arch.memoryDir || ".agentlas");
|
|
7241
|
+
return {
|
|
7242
|
+
root,
|
|
7243
|
+
memoryDir,
|
|
7244
|
+
configPath: path.join(memoryDir, arch.careerGraphConfigFile || "career-graph.json"),
|
|
7245
|
+
sourceManifestPath: path.join(memoryDir, arch.careerGraphSourceManifestFile || "career-graph-sources.json"),
|
|
7246
|
+
inboxPath: path.join(memoryDir, arch.careerGraphInboxDir || "career-graph-inbox"),
|
|
7247
|
+
dbPath: path.join(memoryDir, arch.careerGraphDbFile || "career-graph.sqlite"),
|
|
7248
|
+
};
|
|
7249
|
+
}
|
|
7250
|
+
|
|
7251
|
+
function careerGraphSourceManifestSkeletonCli(root) {
|
|
7252
|
+
return {
|
|
7253
|
+
schemaVersion: "1.0",
|
|
7254
|
+
kind: "agentlas-career-graph-source-manifest",
|
|
7255
|
+
projectRoot: root,
|
|
7256
|
+
sources: [],
|
|
7257
|
+
};
|
|
7258
|
+
}
|
|
7259
|
+
|
|
7260
|
+
function ensureCareerGraphCli(projectPath) {
|
|
7261
|
+
const paths = careerGraphPathsForCli(projectPath);
|
|
7262
|
+
ensureProjectMemoryCli(paths.root, path.basename(paths.root) || "Project");
|
|
7263
|
+
fs.mkdirSync(paths.inboxPath, { recursive: true });
|
|
7264
|
+
if (!fs.existsSync(paths.sourceManifestPath)) {
|
|
7265
|
+
writeJsonSafeCli(paths.sourceManifestPath, careerGraphSourceManifestSkeletonCli(paths.root));
|
|
7266
|
+
}
|
|
7267
|
+
return paths;
|
|
7268
|
+
}
|
|
7269
|
+
|
|
7270
|
+
function readCareerGraphSourcesCli(sourceManifestPath) {
|
|
7271
|
+
const manifest = readJsonSafeCli(sourceManifestPath, { sources: [] });
|
|
7272
|
+
return Array.isArray(manifest.sources) ? manifest.sources : [];
|
|
7273
|
+
}
|
|
7274
|
+
|
|
7275
|
+
function careerGraphUsageLinesCli() {
|
|
7276
|
+
return [
|
|
7277
|
+
"Career Graph commands:",
|
|
7278
|
+
" career-graph status show source-routing files and index state",
|
|
7279
|
+
" career-graph list list inbox files and registered source refs",
|
|
7280
|
+
" career-graph open open the project career graph inbox",
|
|
7281
|
+
" career-graph add ./docs register a folder as private source material",
|
|
7282
|
+
"",
|
|
7283
|
+
"Full graph index commands live in Agentlas OS / Hephaestus:",
|
|
7284
|
+
" hephaestus career-graph ingest --project .",
|
|
7285
|
+
" hephaestus career-graph query \"release failures\" --project .",
|
|
7286
|
+
" hephaestus career-graph verify --project .",
|
|
7287
|
+
"",
|
|
7288
|
+
"Safety: the graph is rebuildable. Markdown, JSONL ledgers, sitemap, and code map stay source of truth.",
|
|
7289
|
+
];
|
|
7290
|
+
}
|
|
7291
|
+
|
|
7292
|
+
function existingCareerGraphCanonicalRefsCli(root) {
|
|
7293
|
+
return [
|
|
7294
|
+
".agentlas/project-soul-memory.md",
|
|
7295
|
+
".agentlas/memory-log.jsonl",
|
|
7296
|
+
".agentlas/curator-decisions.jsonl",
|
|
7297
|
+
".agentlas/sitemap.json",
|
|
7298
|
+
".agentlas/code-map/project-map.json",
|
|
7299
|
+
".agentlas/ledgers/routing-decisions.jsonl",
|
|
7300
|
+
".agentlas/ledgers/executions.jsonl",
|
|
7301
|
+
".agentlas/ledgers/agent-evolution-proposals.jsonl",
|
|
7302
|
+
].filter((rel) => fs.existsSync(path.join(root, rel)));
|
|
7303
|
+
}
|
|
7304
|
+
|
|
7305
|
+
function formatCareerGraphStatusCli(paths) {
|
|
7306
|
+
const sources = readCareerGraphSourcesCli(paths.sourceManifestPath);
|
|
7307
|
+
const inbox = listOntologyInboxCli(paths.inboxPath);
|
|
7308
|
+
const canonical = existingCareerGraphCanonicalRefsCli(paths.root);
|
|
7309
|
+
const lines = [
|
|
7310
|
+
"Career Graph: active",
|
|
7311
|
+
` project: ${paths.root}`,
|
|
7312
|
+
` inbox: ${paths.inboxPath}`,
|
|
7313
|
+
` db: ${paths.dbPath}`,
|
|
7314
|
+
` index: ${fs.existsSync(paths.dbPath) ? "present" : "pending"}`,
|
|
7315
|
+
" policy: ledger_first_derived_index",
|
|
7316
|
+
" source of truth: Markdown / JSONL / JSON files",
|
|
7317
|
+
"",
|
|
7318
|
+
`Canonical source refs (${canonical.length}):`,
|
|
7319
|
+
];
|
|
7320
|
+
for (const rel of canonical) lines.push(` ${rel}`);
|
|
7321
|
+
if (!canonical.length) lines.push(" (none yet)");
|
|
7322
|
+
lines.push("", `Inbox (${inbox.length}):`);
|
|
7323
|
+
for (const item of inbox) lines.push(` ${item.supported ? "ok" : "!"} ${item.name} ${item.supported ? "supported" : "adapter pending"}`);
|
|
7324
|
+
if (!inbox.length) lines.push(" (empty)");
|
|
7325
|
+
lines.push("", `Registered source refs (${sources.length}):`);
|
|
7326
|
+
for (const source of sources) {
|
|
7327
|
+
const sourcePath = path.resolve(String(source.path || ""));
|
|
7328
|
+
lines.push(` ${fs.existsSync(sourcePath) ? "ok" : "!"} ${sourcePath} ${source.kind || "project"} / ${source.scope || "private"}`);
|
|
7329
|
+
}
|
|
7330
|
+
if (!sources.length) lines.push(" (none)");
|
|
7331
|
+
lines.push(
|
|
7332
|
+
"",
|
|
7333
|
+
"Build the derived index with Agentlas OS:",
|
|
7334
|
+
` hephaestus career-graph ingest --project ${JSON.stringify(paths.root)}`,
|
|
7335
|
+
);
|
|
7336
|
+
return lines;
|
|
7337
|
+
}
|
|
7338
|
+
|
|
7339
|
+
function registerCareerGraphSourceCli(paths, source, kind, scope, cwd) {
|
|
7340
|
+
if (!source) throw new Error("usage: career-graph add <path>");
|
|
7341
|
+
const sourcePath = resolveOntologyPathCli(source, cwd || paths.root);
|
|
7342
|
+
if (!fs.existsSync(sourcePath)) throw new Error(`source not found: ${sourcePath}`);
|
|
7343
|
+
const manifest = readJsonSafeCli(paths.sourceManifestPath, careerGraphSourceManifestSkeletonCli(paths.root));
|
|
7344
|
+
const nextSources = (Array.isArray(manifest.sources) ? manifest.sources : [])
|
|
7345
|
+
.filter((item) => path.resolve(String(item.path || "")) !== sourcePath);
|
|
7346
|
+
nextSources.push({ path: sourcePath, kind, scope, registeredAt: new Date().toISOString() });
|
|
7347
|
+
manifest.schemaVersion = "1.0";
|
|
7348
|
+
manifest.kind = "agentlas-career-graph-source-manifest";
|
|
7349
|
+
manifest.projectRoot = paths.root;
|
|
7350
|
+
manifest.sources = nextSources;
|
|
7351
|
+
writeJsonSafeCli(paths.sourceManifestPath, manifest);
|
|
7352
|
+
return [
|
|
7353
|
+
`Registered Career Graph source: ${sourcePath}`,
|
|
5420
7354
|
` kind: ${kind}`,
|
|
5421
7355
|
` scope: ${scope}`,
|
|
5422
7356
|
" copy: no",
|
|
@@ -5424,45 +7358,56 @@ function registerOntologySourceCli(paths, source, kind, scope, cwd) {
|
|
|
5424
7358
|
];
|
|
5425
7359
|
}
|
|
5426
7360
|
|
|
5427
|
-
function
|
|
7361
|
+
function runCareerGraphCli(args, opts) {
|
|
5428
7362
|
opts = opts || {};
|
|
5429
7363
|
const cwd = path.resolve(opts.cwd || process.cwd());
|
|
5430
7364
|
const projectPath = path.resolve(opts.projectPath || cwd);
|
|
5431
7365
|
const normalizedArgs = Array.isArray(args) ? args : [];
|
|
5432
7366
|
const sub = normalizedArgs[0] || "status";
|
|
5433
|
-
const paths =
|
|
7367
|
+
const paths = ensureCareerGraphCli(projectPath);
|
|
5434
7368
|
if (sub === "status" || sub === "list") {
|
|
5435
|
-
return
|
|
7369
|
+
return formatCareerGraphStatusCli(paths);
|
|
5436
7370
|
}
|
|
5437
7371
|
if (sub === "open") {
|
|
5438
7372
|
if (!opts.noOpen) openLocalPathCli(paths.inboxPath);
|
|
5439
|
-
return [`Opened
|
|
7373
|
+
return [`Opened Career Graph inbox: ${paths.inboxPath}`];
|
|
5440
7374
|
}
|
|
5441
7375
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
5442
|
-
return
|
|
7376
|
+
return careerGraphUsageLinesCli();
|
|
5443
7377
|
}
|
|
5444
7378
|
if (sub === "add") {
|
|
5445
7379
|
const flags = parseCloudFlags(normalizedArgs.slice(1));
|
|
5446
7380
|
const source = flags._[0];
|
|
5447
7381
|
const kind = inferOntologyKindCli(flags.kind || flags._[1], normalizedArgs.join(" "));
|
|
5448
7382
|
const scope = inferOntologyScopeCli(flags.scope || flags._[2], normalizedArgs.join(" "), kind);
|
|
5449
|
-
return
|
|
5450
|
-
}
|
|
5451
|
-
if (["company", "personal", "project"].includes(String(sub).toLowerCase())) {
|
|
5452
|
-
const flags = parseCloudFlags(normalizedArgs.slice(1));
|
|
5453
|
-
const kind = inferOntologyKindCli(sub, normalizedArgs.join(" "));
|
|
5454
|
-
const scope = inferOntologyScopeCli(flags.scope || flags._[1], normalizedArgs.join(" "), kind);
|
|
5455
|
-
return registerOntologySourceCli(paths, flags._[0], kind, scope, cwd);
|
|
7383
|
+
return registerCareerGraphSourceCli(paths, source, kind, scope, cwd);
|
|
5456
7384
|
}
|
|
5457
|
-
if (
|
|
5458
|
-
return
|
|
7385
|
+
if (["ingest", "query", "verify", "trace"].includes(String(sub))) {
|
|
7386
|
+
return [
|
|
7387
|
+
"Career Graph index execution is provided by Agentlas OS / Hephaestus.",
|
|
7388
|
+
`Run: hephaestus career-graph ${normalizedArgs.join(" ")} --project ${JSON.stringify(paths.root)}`,
|
|
7389
|
+
];
|
|
5459
7390
|
}
|
|
5460
|
-
return
|
|
7391
|
+
return runCareerGraphCli(parseOntologyNaturalArgsCli(normalizedArgs.join(" "), cwd), opts);
|
|
5461
7392
|
}
|
|
5462
7393
|
|
|
5463
|
-
function
|
|
7394
|
+
function runCareerGraphNaturalCli(text, opts) {
|
|
5464
7395
|
const cwd = path.resolve((opts && opts.cwd) || process.cwd());
|
|
5465
|
-
return
|
|
7396
|
+
return runCareerGraphCli(parseOntologyNaturalArgsCli(text, cwd), { ...(opts || {}), cwd });
|
|
7397
|
+
}
|
|
7398
|
+
|
|
7399
|
+
async function cmdCareerGraph(args) {
|
|
7400
|
+
const sub = String((args && args[0]) || "status");
|
|
7401
|
+
if (["ingest", "query", "verify", "trace", "public-card"].includes(sub)) {
|
|
7402
|
+
const code = await parity().runHephaestusInteractive(["career-graph", ...args], { cwd: process.cwd() });
|
|
7403
|
+
if (code !== 0) process.exitCode = code;
|
|
7404
|
+
return;
|
|
7405
|
+
}
|
|
7406
|
+
try {
|
|
7407
|
+
for (const line of runCareerGraphCli(args, { cwd: process.cwd(), projectPath: process.cwd() })) out(line);
|
|
7408
|
+
} catch (e) {
|
|
7409
|
+
fail((e && e.message) || String(e));
|
|
7410
|
+
}
|
|
5466
7411
|
}
|
|
5467
7412
|
|
|
5468
7413
|
function cmdOntology(args) {
|
|
@@ -5702,67 +7647,154 @@ function resolveRuntime(db, override) {
|
|
|
5702
7647
|
|
|
5703
7648
|
// ── API 러너 (BYOK / Ollama) — 비스트리밍, 최종 텍스트 반환 ──
|
|
5704
7649
|
const DEFAULT_API_MODEL = {
|
|
5705
|
-
anthropic: "claude-sonnet-4-
|
|
7650
|
+
anthropic: "claude-sonnet-4-6",
|
|
5706
7651
|
openai: "gpt-4o-mini",
|
|
5707
7652
|
google: "gemini-1.5-flash",
|
|
5708
7653
|
ollama: "llama3.1",
|
|
5709
7654
|
upstage: "solar-pro2",
|
|
7655
|
+
custom: "deepseek-chat",
|
|
7656
|
+
glm: "glm-4.6",
|
|
7657
|
+
kimi: "kimi-k2-0711-preview",
|
|
7658
|
+
deepseek: "deepseek-chat",
|
|
5710
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" },
|
|
7664
|
+
};
|
|
7665
|
+
const DEFAULT_CUSTOM_API_BASE_URL = "https://api.openai.com/v1";
|
|
7666
|
+
|
|
5711
7667
|
async function apiKey(backend) {
|
|
5712
7668
|
const keytar = readKeytar();
|
|
5713
7669
|
if (!keytar) return null;
|
|
5714
7670
|
// 키체인 접근 거부(서명 안 된 standalone Node)는 "키 없음"으로 조용히 처리.
|
|
5715
7671
|
return keytar.getPassword(SERVICE, "byok:" + backend).catch(() => null);
|
|
5716
7672
|
}
|
|
5717
|
-
|
|
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 || {};
|
|
5718
7732
|
model = model || DEFAULT_API_MODEL[backend];
|
|
5719
|
-
|
|
7733
|
+
const fetchImpl = options.fetch || globalThis.fetch;
|
|
7734
|
+
if (typeof fetchImpl !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임으로 실행 필요).");
|
|
5720
7735
|
if (backend === "ollama") {
|
|
5721
|
-
const resp = await
|
|
7736
|
+
const resp = await fetchImpl("http://127.0.0.1:11434/api/chat", {
|
|
5722
7737
|
method: "POST",
|
|
5723
7738
|
headers: { "content-type": "application/json" },
|
|
5724
7739
|
body: JSON.stringify({ model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
5725
7740
|
});
|
|
5726
|
-
if (!resp.ok)
|
|
7741
|
+
if (!resp.ok) throw new Error(`Ollama ${resp.status} — 'ollama serve' 실행/모델 확인`);
|
|
5727
7742
|
const j = await resp.json();
|
|
5728
7743
|
return (j.message && j.message.content) || "";
|
|
5729
7744
|
}
|
|
5730
|
-
const
|
|
5731
|
-
|
|
5732
|
-
if (
|
|
5733
|
-
|
|
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`, {
|
|
5734
7759
|
method: "POST",
|
|
5735
|
-
headers: { "content-type": "application/json",
|
|
7760
|
+
headers: { "content-type": "application/json", ...authHeaders, "anthropic-version": "2023-06-01" },
|
|
5736
7761
|
body: JSON.stringify({ model, max_tokens: 4096, system, messages: [{ role: "user", content: prompt }] }),
|
|
5737
7762
|
});
|
|
5738
|
-
if (!resp.ok)
|
|
7763
|
+
if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
5739
7764
|
const j = await resp.json();
|
|
5740
7765
|
return (j.content && j.content[0] && j.content[0].text) || "";
|
|
5741
7766
|
}
|
|
5742
|
-
if (backend === "openai" || backend === "upstage") {
|
|
5743
|
-
const base = backend === "upstage"
|
|
5744
|
-
|
|
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`, {
|
|
5745
7777
|
method: "POST",
|
|
5746
7778
|
headers: { "content-type": "application/json", authorization: "Bearer " + key },
|
|
5747
7779
|
body: JSON.stringify({ model, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
5748
7780
|
});
|
|
5749
|
-
if (!resp.ok)
|
|
7781
|
+
if (!resp.ok) throw new Error(`${label} ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
5750
7782
|
const j = await resp.json();
|
|
5751
7783
|
return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
|
|
5752
7784
|
}
|
|
5753
7785
|
if (backend === "google") {
|
|
5754
7786
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`;
|
|
5755
|
-
const resp = await
|
|
7787
|
+
const resp = await fetchImpl(url, {
|
|
5756
7788
|
method: "POST",
|
|
5757
7789
|
headers: { "content-type": "application/json" },
|
|
5758
7790
|
body: JSON.stringify({ systemInstruction: { parts: [{ text: system }] }, contents: [{ role: "user", parts: [{ text: prompt }] }] }),
|
|
5759
7791
|
});
|
|
5760
|
-
if (!resp.ok)
|
|
7792
|
+
if (!resp.ok) throw new Error(`Google ${resp.status}: ${(await resp.text().catch(() => "")).slice(0, 200)}`);
|
|
5761
7793
|
const j = await resp.json();
|
|
5762
7794
|
const c = j.candidates && j.candidates[0];
|
|
5763
7795
|
return (c && c.content && c.content.parts && c.content.parts[0] && c.content.parts[0].text) || "";
|
|
5764
7796
|
}
|
|
5765
|
-
|
|
7797
|
+
throw new Error("지원하지 않는 backend: " + backend);
|
|
5766
7798
|
}
|
|
5767
7799
|
|
|
5768
7800
|
// 1회 실행 — CLI면 spawn(스트리밍 stdout), API면 호출 후 텍스트 출력. 종료코드 반환.
|
|
@@ -5783,7 +7815,7 @@ async function executeOnce(db, system, prompt, override, ctx) {
|
|
|
5783
7815
|
const { Ui } = require("./agentlas-ui.cjs");
|
|
5784
7816
|
const ui = new Ui({ lang: prefsLang() });
|
|
5785
7817
|
let mcpServers = [];
|
|
5786
|
-
if (permission
|
|
7818
|
+
if (permission === "full") {
|
|
5787
7819
|
try {
|
|
5788
7820
|
mcpServers = db.prepare("SELECT id, name, transport, command, args_json, enabled FROM mcp_servers WHERE enabled=1 AND transport='stdio'").all();
|
|
5789
7821
|
} catch { /* ignore */ }
|
|
@@ -5878,25 +7910,10 @@ function runCwd() {
|
|
|
5878
7910
|
}
|
|
5879
7911
|
|
|
5880
7912
|
function cliMcpConfigPath() {
|
|
5881
|
-
|
|
5882
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
5883
|
-
const file = path.join(dir, "agentlas-cli-mcp.json");
|
|
5884
|
-
fs.writeFileSync(
|
|
5885
|
-
file,
|
|
5886
|
-
JSON.stringify({
|
|
5887
|
-
mcpServers: {
|
|
5888
|
-
playwright: { command: "npx", args: ["-y", "@playwright/mcp@latest"] },
|
|
5889
|
-
},
|
|
5890
|
-
}, null, 2),
|
|
5891
|
-
"utf8",
|
|
5892
|
-
);
|
|
5893
|
-
return file;
|
|
7913
|
+
return require("./agentlas-native-host.cjs").cliMcpConfigPath([]).file;
|
|
5894
7914
|
}
|
|
5895
7915
|
|
|
5896
|
-
const CODEX_PLAYWRIGHT_MCP_ARGS = [
|
|
5897
|
-
"-c", 'mcp_servers.playwright.command="npx"',
|
|
5898
|
-
"-c", 'mcp_servers.playwright.args=["-y","@playwright/mcp@latest"]',
|
|
5899
|
-
];
|
|
7916
|
+
const CODEX_PLAYWRIGHT_MCP_ARGS = require("./agentlas-native-host.cjs").codexMcpArgs([]);
|
|
5900
7917
|
|
|
5901
7918
|
// 에이전트가 실제로 실행될 작업 폴더 = 사용자가 명령을 친 현재 디렉터리(= 대상 프로젝트).
|
|
5902
7919
|
// 단, home/userData/agent-cwd 같은 "프로젝트 아님" 위치면 안전한 전용 폴더로 폴백한다.
|
|
@@ -5975,14 +7992,40 @@ function readVaultEnvValuesCli(keys, projectPath) {
|
|
|
5975
7992
|
),
|
|
5976
7993
|
).then(() => result);
|
|
5977
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
|
+
}
|
|
5978
8025
|
async function buildChildEnvCli(db, ctx) {
|
|
5979
8026
|
const env = { ...process.env };
|
|
5980
8027
|
const apply = (values, overwrite) => {
|
|
5981
|
-
|
|
5982
|
-
if (!value) continue;
|
|
5983
|
-
if (!overwrite && env[key]) continue;
|
|
5984
|
-
env[key] = value;
|
|
5985
|
-
}
|
|
8028
|
+
mergeChildEnvValuesCli(env, values, overwrite);
|
|
5986
8029
|
};
|
|
5987
8030
|
const globalCredentials = {
|
|
5988
8031
|
...readDotEnvFileCli(path.join(userDataDir(), "credentials.env")),
|
|
@@ -6009,33 +8052,27 @@ async function buildChildEnvCli(db, ctx) {
|
|
|
6009
8052
|
return env;
|
|
6010
8053
|
}
|
|
6011
8054
|
|
|
6012
|
-
//
|
|
6013
|
-
//
|
|
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.
|
|
6014
8057
|
function buildArgs(kind, systemPrompt, prompt, permission) {
|
|
8058
|
+
const native = require("./agentlas-native-host.cjs");
|
|
8059
|
+
const level = require("./agentlas-permissions.cjs").normalize(permission);
|
|
6015
8060
|
if (kind === "claude-code") {
|
|
6016
|
-
const perm =
|
|
6017
|
-
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
? ["--permission-mode", "acceptEdits"]
|
|
6021
|
-
: [];
|
|
6022
|
-
const mcp = permission === "write" || permission === "full"
|
|
6023
|
-
? ["--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
|
|
6024
|
-
: [];
|
|
8061
|
+
const perm = native.claudePermissionArgs(level);
|
|
8062
|
+
const mcp = level === "full"
|
|
8063
|
+
? ["--strict-mcp-config", "--mcp-config", cliMcpConfigPath(), "--allowedTools", "mcp__playwright"]
|
|
8064
|
+
: native.claudeMcpIsolationArgs();
|
|
6025
8065
|
return ["-p", prompt, "--append-system-prompt", systemPrompt, ...perm, ...mcp];
|
|
6026
8066
|
}
|
|
6027
8067
|
if (kind === "codex") {
|
|
6028
|
-
|
|
6029
|
-
const
|
|
6030
|
-
permission === "full" || permission === "write"
|
|
6031
|
-
? ["--dangerously-bypass-approvals-and-sandbox"]
|
|
6032
|
-
: ["--sandbox", "read-only", "--ask-for-approval", "never"];
|
|
6033
|
-
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 : [];
|
|
6034
8070
|
return ["exec", "--skip-git-repo-check", ...perm, ...mcp, `[SYSTEM]\n${systemPrompt}\n\n${prompt}`];
|
|
6035
8071
|
}
|
|
6036
8072
|
if (kind === "gemini") {
|
|
6037
|
-
const perm =
|
|
6038
|
-
|
|
8073
|
+
const perm = native.geminiPermissionArgs(level);
|
|
8074
|
+
const mcp = level === "full" ? [] : native.geminiMcpIsolationArgs();
|
|
8075
|
+
return ["--prompt", `[SYSTEM]\n${systemPrompt}\n\n${prompt}`, ...perm, ...mcp];
|
|
6039
8076
|
}
|
|
6040
8077
|
return [prompt];
|
|
6041
8078
|
}
|
|
@@ -6051,7 +8088,7 @@ function launchInteractive(db, agent, runtimeOverride) {
|
|
|
6051
8088
|
id: agent.id,
|
|
6052
8089
|
slug: agent.slug,
|
|
6053
8090
|
label: agent.name,
|
|
6054
|
-
system: agent
|
|
8091
|
+
system: agentSystemPromptCli(agent),
|
|
6055
8092
|
capAgent: agent,
|
|
6056
8093
|
};
|
|
6057
8094
|
return launchTui(db, subject, runtimeOverride);
|
|
@@ -6082,7 +8119,7 @@ function buildHelpers(db) {
|
|
|
6082
8119
|
autoRoutePreamble: (choice, lang) => autoRoutePreamble(choice, lang),
|
|
6083
8120
|
cliMemoryContext: (db_, pp) => cliMemoryContext(db_, pp),
|
|
6084
8121
|
importLocal: (db_, p) => importLocalFolderCli(db_, p),
|
|
6085
|
-
// REPL-safe
|
|
8122
|
+
// REPL-safe public Hub install: fail()(process.exit) 대신 Error를 throw 해 REPL이 직접 렌더하게 한다.
|
|
6086
8123
|
cloudInstall: async (db_, slug) => {
|
|
6087
8124
|
if (typeof fetch !== "function") throw new Error("이 런타임에 fetch가 없습니다(앱 런타임 필요).");
|
|
6088
8125
|
const base = process.env.AGENTLAS_MCP_BASE_URL || "https://agentlas.cloud/api/mcp/v1";
|
|
@@ -6091,22 +8128,25 @@ function buildHelpers(db) {
|
|
|
6091
8128
|
if (cookie) headers.cookie = cookie;
|
|
6092
8129
|
let resp;
|
|
6093
8130
|
try {
|
|
6094
|
-
resp = await
|
|
8131
|
+
resp = await fetchHubCli(`${base.replace(/\/$/, "")}/tools/call`, {
|
|
6095
8132
|
method: "POST",
|
|
6096
8133
|
headers,
|
|
6097
8134
|
body: JSON.stringify({ method: "marketplace.get_manifest", params: { name: "marketplace.get_manifest", arguments: { kind: "agent", slug } } }),
|
|
6098
8135
|
});
|
|
6099
8136
|
} catch (e) {
|
|
6100
|
-
throw new Error(
|
|
8137
|
+
throw new Error(`Hub 연결 실패: ${(e && e.message) || e}`);
|
|
6101
8138
|
}
|
|
6102
8139
|
if (!resp.ok) {
|
|
6103
8140
|
const authHint = resp.status === 401 || resp.status === 403 ? " — 로그인이 필요합니다 (앱에서 로그인 또는 AGENTLAS_SESSION 설정)" : "";
|
|
6104
|
-
throw new Error(
|
|
8141
|
+
throw new Error(`Hub 응답 ${resp.status}${authHint}`);
|
|
6105
8142
|
}
|
|
6106
|
-
const json =
|
|
6107
|
-
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");
|
|
6108
8145
|
const listing = json.result;
|
|
6109
|
-
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
|
+
}
|
|
6110
8150
|
return persistCloudListingCli(db_, listing);
|
|
6111
8151
|
},
|
|
6112
8152
|
hasCloudSession: async () => {
|
|
@@ -6130,6 +8170,11 @@ function buildHelpers(db) {
|
|
|
6130
8170
|
stormRun: (db_, goal, ctx) => parity().stormRun(db_, goal, ctx),
|
|
6131
8171
|
swarmRun: (db_, goal, ctx) => parity().swarmRun(db_, goal, ctx),
|
|
6132
8172
|
hepRun: (args, opts) => parity().runHephaestusInteractive(args, opts),
|
|
8173
|
+
cloudSearch: (db_, args) => parity().cloudSearch(db_, args),
|
|
8174
|
+
careerGraphCommand: (text, ctx) => runCareerGraphNaturalCli(text, {
|
|
8175
|
+
cwd: (ctx && ctx.cwd) || projectCwd(),
|
|
8176
|
+
projectPath: (ctx && ctx.cwd) || projectCwd(),
|
|
8177
|
+
}),
|
|
6133
8178
|
ontologyCommand: (text, ctx) => runOntologyNaturalCli(text, {
|
|
6134
8179
|
cwd: (ctx && ctx.cwd) || projectCwd(),
|
|
6135
8180
|
projectPath: (ctx && ctx.cwd) || projectCwd(),
|
|
@@ -6212,10 +8257,11 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
|
|
|
6212
8257
|
const cwd = opts.cwd || runCwd();
|
|
6213
8258
|
return new Promise((resolve) => {
|
|
6214
8259
|
const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
|
|
8260
|
+
const env = require("./agentlas-native-host.cjs").runtimeEnvForKind(kind, opts.env || process.env);
|
|
6215
8261
|
const child = spawn(bin, buildArgs(kind, systemPrompt, prompt, opts.permission), {
|
|
6216
8262
|
cwd,
|
|
6217
8263
|
stdio: ["ignore", "inherit", "inherit"],
|
|
6218
|
-
env
|
|
8264
|
+
env,
|
|
6219
8265
|
});
|
|
6220
8266
|
child.on("error", (err) => {
|
|
6221
8267
|
process.stderr.write(`\n실행 실패(${kind}): ${err.message}\n`);
|
|
@@ -6225,32 +8271,160 @@ function spawnRuntime(kind, systemPrompt, prompt, opts) {
|
|
|
6225
8271
|
});
|
|
6226
8272
|
}
|
|
6227
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
|
+
|
|
6228
8282
|
function captureRuntime(kind, systemPrompt, prompt, opts) {
|
|
6229
8283
|
opts = opts || {};
|
|
6230
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);
|
|
6231
8292
|
return new Promise((resolve, reject) => {
|
|
6232
8293
|
const bin = which(RUNTIME_BIN[kind]) || RUNTIME_BIN[kind];
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
env
|
|
6237
|
-
|
|
6238
|
-
|
|
6239
|
-
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
})
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
}
|
|
6246
|
-
|
|
6247
|
-
|
|
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");
|
|
6248
8405
|
if (code && code !== 0) {
|
|
6249
|
-
|
|
8406
|
+
finishReject(new Error(`${kind} exited ${code}: ${stderr.slice(-500)}`));
|
|
6250
8407
|
return;
|
|
6251
8408
|
}
|
|
6252
|
-
|
|
6253
|
-
}
|
|
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
|
+
}
|
|
6254
8428
|
});
|
|
6255
8429
|
}
|
|
6256
8430
|
|
|
@@ -6317,7 +8491,7 @@ function cmdList(db) {
|
|
|
6317
8491
|
|
|
6318
8492
|
function ensureNativeFiles(agent, folder) {
|
|
6319
8493
|
fs.mkdirSync(folder, { recursive: true });
|
|
6320
|
-
const sys = agent
|
|
8494
|
+
const sys = agentSystemPromptCli(agent);
|
|
6321
8495
|
writeIfMissing(path.join(folder, "system-prompt.md"), sys);
|
|
6322
8496
|
const header = `# ${agent.name}\n\n${agent.tagline || ""}\n\n${sys}\n`;
|
|
6323
8497
|
// 네이티브 CLI가 프로젝트 지시로 자동 인식하는 파일들
|
|
@@ -6354,7 +8528,7 @@ async function cmdRun(db, query, prompt, runtimeOverride) {
|
|
|
6354
8528
|
if (!userPrompt) userPrompt = await readStdin();
|
|
6355
8529
|
if (!userPrompt || !userPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 stdin으로 전달하세요.");
|
|
6356
8530
|
process.stderr.write(`▸ ${agent.name}\n`);
|
|
6357
|
-
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 });
|
|
6358
8532
|
process.exit(code);
|
|
6359
8533
|
}
|
|
6360
8534
|
|
|
@@ -6364,7 +8538,7 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
|
|
|
6364
8538
|
if (!choice) fail("자동 라우팅할 에이전트가 없습니다. agentlas list로 설치 상태를 확인하세요.");
|
|
6365
8539
|
process.stderr.write(`▸ ${choice.agent.name} (auto)\n`);
|
|
6366
8540
|
process.stderr.write(` ${autoRouteNote(choice, lang)}\n`);
|
|
6367
|
-
const sys = `${autoRoutePreamble(choice, lang)}\n\n${choice.agent
|
|
8541
|
+
const sys = `${autoRoutePreamble(choice, lang)}\n\n${agentSystemPromptCli(choice.agent)}`;
|
|
6368
8542
|
const code = await executeOnce(db, sys, prompt.trim(), runtimeOverride, {
|
|
6369
8543
|
projectPath: activeProjectPath(db),
|
|
6370
8544
|
agentId: choice.agent.id,
|
|
@@ -6461,7 +8635,15 @@ function upsertEnvLine(file, key, value) {
|
|
|
6461
8635
|
if (re.test(body)) body = body.replace(re, line);
|
|
6462
8636
|
else body = body ? body.replace(/\n?$/, "\n") + line + "\n" : line + "\n";
|
|
6463
8637
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
6464
|
-
|
|
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);
|
|
6465
8647
|
}
|
|
6466
8648
|
async function cmdCredsFile(db, args) {
|
|
6467
8649
|
const f = parseCredFlags(args);
|
|
@@ -6479,7 +8661,7 @@ async function cmdCredsFile(db, args) {
|
|
|
6479
8661
|
ensureLocalCredentialStoreCli(project, projectName, arch);
|
|
6480
8662
|
ensureSoulCredentialIndexCli(project, projectName, arch);
|
|
6481
8663
|
|
|
6482
|
-
const sourceAbs =
|
|
8664
|
+
const sourceAbs = resolveCredentialSourcePath(source);
|
|
6483
8665
|
let stat;
|
|
6484
8666
|
try { stat = fs.statSync(sourceAbs); } catch { fail(`credential source not found: ${source}`); }
|
|
6485
8667
|
if (!stat.isFile()) fail(`credential source is not a file: ${source}`);
|
|
@@ -6732,48 +8914,220 @@ function cmdUpdateHelp() {
|
|
|
6732
8914
|
);
|
|
6733
8915
|
}
|
|
6734
8916
|
|
|
6735
|
-
function
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
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
|
+
};
|
|
8940
|
+
}
|
|
8941
|
+
|
|
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;
|
|
6745
8960
|
}
|
|
6746
8961
|
|
|
6747
|
-
function
|
|
6748
|
-
const
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
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
|
+
}
|
|
6753
9096
|
}
|
|
6754
|
-
return 0;
|
|
6755
9097
|
}
|
|
6756
9098
|
|
|
6757
|
-
function
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
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) };
|
|
6761
9122
|
}
|
|
6762
9123
|
|
|
6763
9124
|
async function fetchDesktopRelease(url) {
|
|
6764
9125
|
const controller = new AbortController();
|
|
6765
|
-
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
6766
9126
|
try {
|
|
6767
|
-
|
|
6768
|
-
if (!resp.ok) fail(`업데이트 정보를 가져오지 못했습니다: ${resp.status}`);
|
|
6769
|
-
const json = await resp.json();
|
|
6770
|
-
if (!json || typeof json !== "object" || !json.version) fail("업데이트 정보 형식이 올바르지 않습니다.");
|
|
6771
|
-
return json;
|
|
9127
|
+
return await fetchUpdateMetadata(url, { signal: controller.signal });
|
|
6772
9128
|
} catch (error) {
|
|
6773
|
-
const message =
|
|
9129
|
+
const message = String((error && error.message) || error);
|
|
6774
9130
|
fail(`업데이트 확인 실패: ${message}`);
|
|
6775
|
-
} finally {
|
|
6776
|
-
clearTimeout(timer);
|
|
6777
9131
|
}
|
|
6778
9132
|
}
|
|
6779
9133
|
|
|
@@ -6808,8 +9162,9 @@ async function cmdUpdateStandalone(flags) {
|
|
|
6808
9162
|
const resp = await fetch("https://registry.npmjs.org/agentlas/latest", { headers: { accept: "application/json" } });
|
|
6809
9163
|
if (resp.ok) latestVersion = String((await resp.json()).version || "");
|
|
6810
9164
|
} catch { /* offline 등 — 아래에서 안내 */ }
|
|
9165
|
+
const comparison = latestVersion ? compareSemVer(currentVersion, latestVersion) : null;
|
|
6811
9166
|
if (flags.json) {
|
|
6812
|
-
return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable:
|
|
9167
|
+
return out(JSON.stringify({ currentVersion, latestVersion, updateAvailable: comparison == null ? null : comparison < 0, channel: "npm" }, null, 2));
|
|
6813
9168
|
}
|
|
6814
9169
|
out(`현재 버전: ${currentVersion}`);
|
|
6815
9170
|
if (!latestVersion) {
|
|
@@ -6818,7 +9173,9 @@ async function cmdUpdateStandalone(flags) {
|
|
|
6818
9173
|
return;
|
|
6819
9174
|
}
|
|
6820
9175
|
out(`최신 버전: ${latestVersion}`);
|
|
6821
|
-
if (
|
|
9176
|
+
if (comparison == null) {
|
|
9177
|
+
out("버전 형식을 비교하지 못했습니다. 수동 업데이트: npm i -g agentlas@latest");
|
|
9178
|
+
} else if (comparison < 0) {
|
|
6822
9179
|
out("업데이트: npm i -g agentlas@latest");
|
|
6823
9180
|
} else {
|
|
6824
9181
|
out("이미 최신 버전입니다.");
|
|
@@ -6835,7 +9192,9 @@ async function cmdUpdate(args) {
|
|
|
6835
9192
|
const release = await fetchDesktopRelease(flags.url);
|
|
6836
9193
|
const latestVersion = String(release.version || "");
|
|
6837
9194
|
const artifact = findCurrentArtifact(release);
|
|
6838
|
-
const
|
|
9195
|
+
const comparison = compareSemVer(currentVersion, latestVersion);
|
|
9196
|
+
if (comparison == null) fail(`현재/최신 버전이 SemVer 형식이 아닙니다: current=${currentVersion} latest=${latestVersion}`);
|
|
9197
|
+
const updateAvailable = comparison < 0;
|
|
6839
9198
|
const status = {
|
|
6840
9199
|
currentVersion,
|
|
6841
9200
|
latestVersion,
|
|
@@ -6892,17 +9251,80 @@ function sleep(ms) {
|
|
|
6892
9251
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6893
9252
|
}
|
|
6894
9253
|
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
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}`);
|
|
6902
9290
|
}
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
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;
|
|
6906
9328
|
}
|
|
6907
9329
|
}
|
|
6908
9330
|
|
|
@@ -6919,10 +9341,199 @@ function macAppInstallPath() {
|
|
|
6919
9341
|
return "/Applications/Agentlas.app";
|
|
6920
9342
|
}
|
|
6921
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
|
+
|
|
6922
9532
|
async function installMacDesktopUpdate(release, artifact, flags) {
|
|
6923
9533
|
const hdiutil = requirePath("/usr/bin/hdiutil", "hdiutil");
|
|
6924
9534
|
const xcrun = requirePath("/usr/bin/xcrun", "xcrun");
|
|
6925
9535
|
const spctl = requirePath("/usr/sbin/spctl", "spctl");
|
|
9536
|
+
const codesign = requirePath("/usr/bin/codesign", "codesign");
|
|
6926
9537
|
const osascript = requirePath("/usr/bin/osascript", "osascript");
|
|
6927
9538
|
const ditto = requirePath("/usr/bin/ditto", "ditto");
|
|
6928
9539
|
const plistBuddy = requirePath("/usr/libexec/PlistBuddy", "PlistBuddy");
|
|
@@ -6931,16 +9542,21 @@ async function installMacDesktopUpdate(release, artifact, flags) {
|
|
|
6931
9542
|
const open = requirePath("/usr/bin/open", "open");
|
|
6932
9543
|
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
6933
9544
|
|
|
9545
|
+
const validatedArtifact = validateDesktopUpdateArtifact(artifact);
|
|
6934
9546
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentlas-update."));
|
|
6935
|
-
const fileName =
|
|
9547
|
+
const fileName = validatedArtifact.fileName || `Agentlas-${macReleaseArch() || "mac"}.dmg`;
|
|
6936
9548
|
const dmgPath = path.join(tmpDir, fileName);
|
|
6937
9549
|
let mountPoint = "";
|
|
6938
|
-
let backupPath = "";
|
|
6939
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`);
|
|
6940
9556
|
|
|
6941
9557
|
try {
|
|
6942
9558
|
out(`다운로드: ${fileName}`);
|
|
6943
|
-
await downloadUpdateFile(
|
|
9559
|
+
await downloadUpdateFile(validatedArtifact.url, dmgPath, validatedArtifact);
|
|
6944
9560
|
out("검증: DMG, notarization, Gatekeeper");
|
|
6945
9561
|
await runCommand(hdiutil, ["verify", dmgPath]);
|
|
6946
9562
|
await runCommand(xcrun, ["stapler", "validate", dmgPath]);
|
|
@@ -6949,34 +9565,29 @@ async function installMacDesktopUpdate(release, artifact, flags) {
|
|
|
6949
9565
|
const mount = await runCommand(hdiutil, ["attach", "-nobrowse", "-readonly", dmgPath], { capture: true });
|
|
6950
9566
|
mountPoint = parseHdiutilMountPoint(mount.stdout);
|
|
6951
9567
|
const sourceApp = mountPoint ? path.join(mountPoint, "Agentlas.app") : "";
|
|
6952
|
-
if (!sourceApp || !fs.existsSync(sourceApp))
|
|
9568
|
+
if (!sourceApp || !fs.existsSync(sourceApp)) {
|
|
9569
|
+
throw updateTransferError("AGENTLAS_UPDATE_APP_MISSING", "DMG 안에서 Agentlas.app을 찾지 못했습니다.");
|
|
9570
|
+
}
|
|
6953
9571
|
|
|
6954
9572
|
const installedVersion = await runCommand(plistBuddy, ["-c", "Print :CFBundleShortVersionString", path.join(sourceApp, "Contents", "Info.plist")], { capture: true });
|
|
6955
9573
|
const appVersion = installedVersion.stdout.trim();
|
|
6956
|
-
if (appVersion !== String(release.version))
|
|
6957
|
-
|
|
9574
|
+
if (appVersion !== String(release.version)) {
|
|
9575
|
+
throw updateTransferError("AGENTLAS_UPDATE_VERSION_MISMATCH", `앱 버전이 릴리즈와 다릅니다: release=${release.version} app=${appVersion}`);
|
|
9576
|
+
}
|
|
6958
9577
|
|
|
6959
9578
|
out("설치: 기존 Agentlas 종료 후 앱 교체");
|
|
6960
9579
|
await runCommand(osascript, ["-e", 'tell application "Agentlas" to quit'], { capture: true, allowFailure: true });
|
|
6961
9580
|
await sleep(2_000);
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
|
|
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}`);
|
|
6967
9590
|
if (fs.existsSync(lsregister)) await runCommand(lsregister, ["-f", targetApp], { allowFailure: true });
|
|
6968
|
-
|
|
6969
|
-
try {
|
|
6970
|
-
await runCommand(spctl, ["-a", "-vv", targetApp]);
|
|
6971
|
-
} catch (error) {
|
|
6972
|
-
if (backupPath && fs.existsSync(backupPath)) {
|
|
6973
|
-
await runCommand(rm, ["-rf", targetApp], { allowFailure: true });
|
|
6974
|
-
await runCommand(mv, [backupPath, targetApp], { allowFailure: true });
|
|
6975
|
-
}
|
|
6976
|
-
throw error;
|
|
6977
|
-
}
|
|
6978
|
-
|
|
6979
|
-
if (backupPath && fs.existsSync(backupPath)) await runCommand(rm, ["-rf", backupPath], { allowFailure: true });
|
|
6980
9591
|
if (flags.launch) await runCommand(open, ["-a", "Agentlas"], { allowFailure: true });
|
|
6981
9592
|
out(`Agentlas ${release.version} 설치 완료.`);
|
|
6982
9593
|
} finally {
|
|
@@ -7300,56 +9911,66 @@ async function cmdOberon(args) {
|
|
|
7300
9911
|
}
|
|
7301
9912
|
|
|
7302
9913
|
function cmdHelp() {
|
|
9914
|
+
const H = (s) => `\n\x1b[1m${s}\x1b[0m`;
|
|
9915
|
+
const useColor = process.stdout.isTTY && process.env.NO_COLOR == null;
|
|
9916
|
+
const hdr = (s) => (useColor ? H(s) : "\n" + s);
|
|
7303
9917
|
out(
|
|
7304
9918
|
[
|
|
7305
|
-
"agentlas —
|
|
9919
|
+
"agentlas — the operating system for agents, in your terminal",
|
|
9920
|
+
"",
|
|
9921
|
+
" agentlas open the terminal (wordmark, then type a task)",
|
|
9922
|
+
" agentlas \"<task>\" auto-route to the best agent and run once",
|
|
9923
|
+
"",
|
|
9924
|
+
hdr("TALK & RUN"),
|
|
9925
|
+
" <agent> jump into a chat with one agent (e.g. agentlas seo)",
|
|
9926
|
+
" run [agent] [prompt] one-shot — omit agent to auto-route (reads stdin if no prompt)",
|
|
9927
|
+
" firm <firm> [cmd] delegate to a company's CEO (interactive if no cmd)",
|
|
9928
|
+
" chats [n] recent conversations · chat resume in REPL: /resume",
|
|
9929
|
+
"",
|
|
9930
|
+
hdr("AGENTS & HUB (Agentlas OS surface)"),
|
|
9931
|
+
" search \"<what you need>\" discover agents in the Hub + local (hep-search)",
|
|
9932
|
+
" install <slug> install an agent from the Hub (hep-cloud)",
|
|
9933
|
+
" build \"<request>\" build/repair/package an agent or team (hep-build)",
|
|
9934
|
+
" upload <path> save owner-private in Agent Cloud (default) (hep-upload)",
|
|
9935
|
+
" --visibility marketplace explicit compatibility flag: publish to Hub",
|
|
9936
|
+
" connect [<sub>] wire Telegram / platforms to an agent team (hep-connect)",
|
|
9937
|
+
" import <path> import a local agent/team folder",
|
|
9938
|
+
" list installed agents/companies + active runtime",
|
|
7306
9939
|
"",
|
|
7307
|
-
"
|
|
7308
|
-
"
|
|
7309
|
-
"
|
|
7310
|
-
"
|
|
7311
|
-
"
|
|
7312
|
-
"
|
|
7313
|
-
"
|
|
7314
|
-
" cd <agent> print the agent folder — cd \"$(agentlas cd seo)\" && claude",
|
|
7315
|
-
" list agents/companies + active runtime",
|
|
7316
|
-
" env shared env key names",
|
|
7317
|
-
" multimodal image/video/audio fallback providers",
|
|
7318
|
-
" oberon <sub> AI film render from the terminal (scaffold|render|list) — see: oberon help",
|
|
7319
|
-
" ontology project-local ontology status/list/add; inside REPL use /ontology",
|
|
7320
|
-
" storm <goal> Stormbreaker force-robust pipeline (route → verify → execute) [--research]",
|
|
7321
|
-
" swarm <goal> emergent agent swarm — parallel workers + blackboard + synthesizer [--parallel N]",
|
|
7322
|
-
" build \"<request>\" build/repair/package an agent or team (Hephaestus hep-build)",
|
|
7323
|
-
" route \"<request>\" routing preview — which agent/pipeline would take this",
|
|
7324
|
-
" research <sub> Research Engine: status|gather|search|read|plan …",
|
|
7325
|
-
" network <sub> local agent network: init|status|reindex|add-source …",
|
|
7326
|
-
" journal <sub> Stormbreaker run journal: status|verify|repair|gate",
|
|
7327
|
-
" call \"a,b\" \"<ctx>\" prepare named Hub/Cloud agents (hep-call)",
|
|
7328
|
-
" hep <sub…> full Hephaestus passthrough (wizard·security·cards·ao·plugins…)",
|
|
7329
|
-
" mcp installed MCP servers (shared with the app)",
|
|
7330
|
-
" chats [n] recent app/terminal chats",
|
|
7331
|
-
" automation <sub> list|add|on|off|remove|run <id>|runs|daemon — local runner included",
|
|
7332
|
-
" login | logout | whoami",
|
|
7333
|
-
" Agentlas Cloud sign-in (browser flow) — marketplace install/publish",
|
|
7334
|
-
" usage local usage summary (runs, messages, automations)",
|
|
7335
|
-
" telegram telegram binding status (pairing lives in the app)",
|
|
7336
|
-
" cloud wizard <path> create/repair agentlas.json for Cloud MCP calls",
|
|
7337
|
-
" cloud security scan <path>",
|
|
7338
|
-
" risk-screen an agent folder before run/publish",
|
|
7339
|
-
" cloud runtime bundle <path>",
|
|
7340
|
-
" compile manifest-based runtime bundle",
|
|
7341
|
-
" cloud field-test run local Cloud contract fixture test",
|
|
7342
|
-
" cloud package <path> package + static security review for Agentlas Cloud",
|
|
7343
|
-
" cloud publish <path> register after local review (submitter runtime only)",
|
|
7344
|
-
" cloud install <slug> download/install a cloud marketplace agent",
|
|
7345
|
-
" creds save ... save an issued key (project vault + project .env + project-scoped global env)",
|
|
7346
|
-
" creds file ... copy a credential file into signing/credentials and set an env path",
|
|
7347
|
-
" update check and install the latest Agentlas Desktop release",
|
|
7348
|
-
" doctor check runtimes and data",
|
|
7349
|
-
" setup re-run first-launch setup (language · runtime · permission)",
|
|
7350
|
-
" version print the Agentlas CLI version",
|
|
9940
|
+
hdr("EXECUTE"),
|
|
9941
|
+
" storm <goal> force-robust pipeline: route → verify → execute (Stormbreaker) [--research]",
|
|
9942
|
+
" swarm <goal> emergent agent swarm — parallel workers + synthesizer [--parallel N]",
|
|
9943
|
+
" network <request> decompose a request into an A2A task force (hep-network)",
|
|
9944
|
+
" call \"a,b\" \"<ctx>\" invoke named Hub/Cloud agents (hep-call)",
|
|
9945
|
+
" browser [<sub>] real browser execution hardpoint (hep-browser)",
|
|
9946
|
+
" route \"<request>\" routing preview — which agent/pipeline would take this",
|
|
7351
9947
|
"",
|
|
7352
|
-
"
|
|
9948
|
+
hdr("KNOWLEDGE & RESEARCH"),
|
|
9949
|
+
" research <sub> Research Engine: status|gather|search|read|plan",
|
|
9950
|
+
" career-graph <sub> source routing index: status|list|add",
|
|
9951
|
+
" ontology <sub> project knowledge: status|list|add (REPL: /ontology)",
|
|
9952
|
+
" journal <sub> Stormbreaker run journal: status|verify|repair|gate",
|
|
9953
|
+
"",
|
|
9954
|
+
hdr("ACCOUNT & OPS"),
|
|
9955
|
+
" login | logout | whoami Agentlas Cloud sign-in (browser flow)",
|
|
9956
|
+
" automation <sub> list|add|on|off|remove|run <id>|runs|daemon (local scheduler)",
|
|
9957
|
+
" creds <sub> · env credentials vault and shared env keys",
|
|
9958
|
+
" multimodal image/video/audio provider settings",
|
|
9959
|
+
" usage · telegram · mcp local usage · telegram bindings · MCP servers",
|
|
9960
|
+
" doctor check runtimes, data, credentials",
|
|
9961
|
+
" update check for a newer agentlas on npm",
|
|
9962
|
+
" setup re-run first-launch setup (language · runtime · permission)",
|
|
9963
|
+
" version print the Agentlas CLI version",
|
|
9964
|
+
"",
|
|
9965
|
+
hdr("ADVANCED"),
|
|
9966
|
+
" hep <sub…> full Hephaestus passthrough (wizard·security·cards·ao·plugins·meta-agent…)",
|
|
9967
|
+
" netadmin <sub> local network admin: init|status|reindex|bench|add-source",
|
|
9968
|
+
" cloud <sub> cloud assets: save|publish|package|list|restore|field-test",
|
|
9969
|
+
" cd <agent> print the agent folder — cd \"$(agentlas cd seo)\" && claude",
|
|
9970
|
+
" oberon <sub> AI film render (scaffold|render|list)",
|
|
9971
|
+
"",
|
|
9972
|
+
"Options: --runtime claude-code|codex|gemini · --permission read|write|full (default write)",
|
|
9973
|
+
"In the REPL, type / for the command palette (/build /route /research /storm /swarm …).",
|
|
7353
9974
|
].join("\n"),
|
|
7354
9975
|
);
|
|
7355
9976
|
}
|
|
@@ -7396,6 +10017,10 @@ async function main() {
|
|
|
7396
10017
|
|
|
7397
10018
|
const db = openDb();
|
|
7398
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
|
+
|
|
7399
10024
|
// Agentlas 아키텍처 빌트인 에이전트를 보장(앱과 동일, 멱등·버전 게이팅). 스키마가 준비됐을 때만.
|
|
7400
10025
|
try { seedBuiltins(db); } catch { /* best-effort */ }
|
|
7401
10026
|
|
|
@@ -7429,6 +10054,10 @@ async function main() {
|
|
|
7429
10054
|
return cmdOberon(rest.slice(1));
|
|
7430
10055
|
case "ontology":
|
|
7431
10056
|
return cmdOntology(rest.slice(1));
|
|
10057
|
+
case "career-graph":
|
|
10058
|
+
case "career_graph":
|
|
10059
|
+
case "graph":
|
|
10060
|
+
return cmdCareerGraph(rest.slice(1));
|
|
7432
10061
|
case "cloud":
|
|
7433
10062
|
return cmdCloud(db, rest.slice(1), runtimeOverride);
|
|
7434
10063
|
case "creds":
|
|
@@ -7443,24 +10072,43 @@ async function main() {
|
|
|
7443
10072
|
case "hep":
|
|
7444
10073
|
case "hephaestus":
|
|
7445
10074
|
return parity().cmdHep(db, rest.slice(1));
|
|
10075
|
+
// ── Agentlas OS 정식 표면 (hep-*) 1급 노출 ──
|
|
7446
10076
|
case "build":
|
|
7447
|
-
// hep-build "<요청>" —
|
|
10077
|
+
// hep-build "<요청>" — 에이전트/팀 빌드 (Meta-Agent Factory)
|
|
7448
10078
|
return parity().cmdHep(db, rest.length > 1 ? ["hep-build", rest.slice(1).join(" ")] : ["hep-build"]);
|
|
7449
|
-
case "
|
|
10079
|
+
case "search": // hep-search — 에이전트 디렉터리 발견 (Hub + 로컬)
|
|
10080
|
+
if (!rest[1]) return fail('usage: agentlas search "<찾는 일>" [--limit 10]');
|
|
10081
|
+
return parity().cloudSearch(db, rest.slice(1));
|
|
10082
|
+
case "install": // public Hub package install — slug로 에이전트 설치
|
|
10083
|
+
if (!rest[1]) return fail('usage: agentlas install <slug> (먼저 agentlas search "할 일" 로 찾으세요)');
|
|
10084
|
+
return cmdCloudInstall(db, rest[1]);
|
|
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
|
+
}
|
|
10090
|
+
case "connect": // hep-connect — Telegram 등 플랫폼 연결
|
|
10091
|
+
return parity().cmdHep(db, ["hep-connect", ...rest.slice(1)]);
|
|
10092
|
+
case "browser": // hep-browser — 실제 브라우저 실행 하드포인트
|
|
10093
|
+
return parity().cmdHep(db, ["hep-browser", ...rest.slice(1)]);
|
|
10094
|
+
case "call": // hep-call — 지정 에이전트 호출/준비
|
|
10095
|
+
return parity().cmdHep(db, ["hep-call", ...rest.slice(1)]);
|
|
10096
|
+
case "network": // hep-network — A2A 태스크포스 분해/스케줄
|
|
10097
|
+
case "taskforce":
|
|
10098
|
+
return parity().cmdHep(db, ["hep-network", ...rest.slice(1)]);
|
|
10099
|
+
case "route": // 라우팅 미리보기 (실행 없음)
|
|
7450
10100
|
return parity().cmdHep(
|
|
7451
10101
|
db,
|
|
7452
10102
|
rest.length > 1
|
|
7453
10103
|
? ["route", rest.slice(1).join(" "), "--project", runCwd(), "--runtime", "terminal"]
|
|
7454
10104
|
: ["route"],
|
|
7455
10105
|
);
|
|
7456
|
-
case "research":
|
|
10106
|
+
case "research": // Research Engine
|
|
7457
10107
|
return parity().cmdHep(db, ["research", ...rest.slice(1)]);
|
|
7458
|
-
case "
|
|
10108
|
+
case "netadmin": // 로컬 에이전트 네트워크 관리 (init|status|reindex|bench|add-source)
|
|
7459
10109
|
return parity().cmdHep(db, ["network", ...rest.slice(1)]);
|
|
7460
|
-
case "journal":
|
|
10110
|
+
case "journal": // Stormbreaker 런 저널
|
|
7461
10111
|
return parity().cmdHep(db, ["stormbreaker", "journal", ...rest.slice(1)]);
|
|
7462
|
-
case "call":
|
|
7463
|
-
return parity().cmdHep(db, ["hep-call", ...rest.slice(1)]);
|
|
7464
10112
|
case "mcp":
|
|
7465
10113
|
return parity().cmdMcp(db);
|
|
7466
10114
|
case "chats":
|
|
@@ -7500,4 +10148,51 @@ async function main() {
|
|
|
7500
10148
|
}
|
|
7501
10149
|
}
|
|
7502
10150
|
|
|
7503
|
-
|
|
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
|
+
};
|