@mono-agent/agent-app 0.6.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +259 -19
- package/dist/account-home.d.ts +6 -0
- package/dist/account-home.d.ts.map +1 -0
- package/dist/account-home.js +9 -0
- package/dist/account-home.js.map +1 -0
- package/dist/adapter-send-proxy.d.ts +18 -0
- package/dist/adapter-send-proxy.d.ts.map +1 -0
- package/dist/adapter-send-proxy.js +229 -0
- package/dist/adapter-send-proxy.js.map +1 -0
- package/dist/adapter-send-tools-main.js +62 -4
- package/dist/adapter-send-tools-main.js.map +1 -1
- package/dist/adapter-send-tools.d.ts +33 -6
- package/dist/adapter-send-tools.d.ts.map +1 -1
- package/dist/adapter-send-tools.js +485 -87
- package/dist/adapter-send-tools.js.map +1 -1
- package/dist/app-config.d.ts +3 -1
- package/dist/app-config.d.ts.map +1 -1
- package/dist/app-config.js +16 -5
- package/dist/app-config.js.map +1 -1
- package/dist/app.d.ts +27 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +761 -147
- package/dist/app.js.map +1 -1
- package/dist/background-environment.d.ts +16 -0
- package/dist/background-environment.d.ts.map +1 -0
- package/dist/background-environment.js +68 -0
- package/dist/background-environment.js.map +1 -0
- package/dist/background-runtime.d.ts +62 -0
- package/dist/background-runtime.d.ts.map +1 -0
- package/dist/background-runtime.js +1115 -0
- package/dist/background-runtime.js.map +1 -0
- package/dist/background-snapshot-key.d.ts +13 -0
- package/dist/background-snapshot-key.d.ts.map +1 -0
- package/dist/background-snapshot-key.js +194 -0
- package/dist/background-snapshot-key.js.map +1 -0
- package/dist/background-snapshot.d.ts +96 -0
- package/dist/background-snapshot.d.ts.map +1 -0
- package/dist/background-snapshot.js +450 -0
- package/dist/background-snapshot.js.map +1 -0
- package/dist/background-worker-lease.d.ts +39 -0
- package/dist/background-worker-lease.d.ts.map +1 -0
- package/dist/background-worker-lease.js +356 -0
- package/dist/background-worker-lease.js.map +1 -0
- package/dist/background.d.ts +74 -2
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js +815 -73
- package/dist/background.js.map +1 -1
- package/dist/broadcast-recorder.d.ts.map +1 -1
- package/dist/broadcast-recorder.js +24 -2
- package/dist/broadcast-recorder.js.map +1 -1
- package/dist/channel-plugins.d.ts +2 -0
- package/dist/channel-plugins.d.ts.map +1 -1
- package/dist/channel-plugins.js +8 -0
- package/dist/channel-plugins.js.map +1 -1
- package/dist/channels.d.ts +10 -0
- package/dist/channels.d.ts.map +1 -1
- package/dist/channels.js +87 -9
- package/dist/channels.js.map +1 -1
- package/dist/cli.d.ts +86 -4
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2421 -143
- package/dist/cli.js.map +1 -1
- package/dist/config-reference.d.ts.map +1 -1
- package/dist/config-reference.js +448 -6
- package/dist/config-reference.js.map +1 -1
- package/dist/configuration-proposal-main.d.ts +3 -0
- package/dist/configuration-proposal-main.d.ts.map +1 -0
- package/dist/configuration-proposal-main.js +13 -0
- package/dist/configuration-proposal-main.js.map +1 -0
- package/dist/configuration-proposal-tool.d.ts +33 -0
- package/dist/configuration-proposal-tool.d.ts.map +1 -0
- package/dist/configuration-proposal-tool.js +188 -0
- package/dist/configuration-proposal-tool.js.map +1 -0
- package/dist/configured-agent.d.ts +16 -0
- package/dist/configured-agent.d.ts.map +1 -1
- package/dist/configured-agent.js +165 -18
- package/dist/configured-agent.js.map +1 -1
- package/dist/consumer-contract.d.ts +3 -0
- package/dist/consumer-contract.d.ts.map +1 -1
- package/dist/consumer-contract.js +44 -1
- package/dist/consumer-contract.js.map +1 -1
- package/dist/continuation-command.d.ts +15 -0
- package/dist/continuation-command.d.ts.map +1 -0
- package/dist/continuation-command.js +170 -0
- package/dist/continuation-command.js.map +1 -0
- package/dist/continuation-config.d.ts +25 -0
- package/dist/continuation-config.d.ts.map +1 -0
- package/dist/continuation-config.js +236 -0
- package/dist/continuation-config.js.map +1 -0
- package/dist/continuation-service.d.ts +99 -0
- package/dist/continuation-service.d.ts.map +1 -0
- package/dist/continuation-service.js +1313 -0
- package/dist/continuation-service.js.map +1 -0
- package/dist/continuation-store.d.ts +104 -0
- package/dist/continuation-store.d.ts.map +1 -0
- package/dist/continuation-store.js +688 -0
- package/dist/continuation-store.js.map +1 -0
- package/dist/continuations.d.ts +167 -0
- package/dist/continuations.d.ts.map +1 -0
- package/dist/continuations.js +71 -0
- package/dist/continuations.js.map +1 -0
- package/dist/doctor.d.ts +29 -5
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +1611 -116
- package/dist/doctor.js.map +1 -1
- package/dist/first-run-managed-memory.d.ts +39 -0
- package/dist/first-run-managed-memory.d.ts.map +1 -0
- package/dist/first-run-managed-memory.js +584 -0
- package/dist/first-run-managed-memory.js.map +1 -0
- package/dist/first-run-readiness.d.ts +111 -0
- package/dist/first-run-readiness.d.ts.map +1 -0
- package/dist/first-run-readiness.js +728 -0
- package/dist/first-run-readiness.js.map +1 -0
- package/dist/index.d.ts +17 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +95 -3
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +1218 -24
- package/dist/init.js.map +1 -1
- package/dist/interaction-bridge.d.ts +25 -1
- package/dist/interaction-bridge.d.ts.map +1 -1
- package/dist/interaction-bridge.js +281 -19
- package/dist/interaction-bridge.js.map +1 -1
- package/dist/launchd.d.ts +16 -6
- package/dist/launchd.d.ts.map +1 -1
- package/dist/launchd.js +30 -15
- package/dist/launchd.js.map +1 -1
- package/dist/local-configuration.d.ts +99 -0
- package/dist/local-configuration.d.ts.map +1 -0
- package/dist/local-configuration.js +1299 -0
- package/dist/local-configuration.js.map +1 -0
- package/dist/managed-runtime-packages.d.ts +9 -0
- package/dist/managed-runtime-packages.d.ts.map +1 -0
- package/dist/managed-runtime-packages.js +56 -0
- package/dist/managed-runtime-packages.js.map +1 -0
- package/dist/memory-command.d.ts +4 -0
- package/dist/memory-command.d.ts.map +1 -1
- package/dist/memory-command.js +751 -27
- package/dist/memory-command.js.map +1 -1
- package/dist/memory-embedding-service.d.ts +33 -0
- package/dist/memory-embedding-service.d.ts.map +1 -0
- package/dist/memory-embedding-service.js +207 -0
- package/dist/memory-embedding-service.js.map +1 -0
- package/dist/memory-recall.d.ts +43 -14
- package/dist/memory-recall.d.ts.map +1 -1
- package/dist/memory-recall.js +103 -33
- package/dist/memory-recall.js.map +1 -1
- package/dist/memory-retrieval.d.ts +66 -0
- package/dist/memory-retrieval.d.ts.map +1 -0
- package/dist/memory-retrieval.js +332 -0
- package/dist/memory-retrieval.js.map +1 -0
- package/dist/modules/base.d.ts +6 -5
- package/dist/modules/base.d.ts.map +1 -1
- package/dist/modules/base.js +11 -7
- package/dist/modules/base.js.map +1 -1
- package/dist/modules/catalog.d.ts.map +1 -1
- package/dist/modules/catalog.js +141 -25
- package/dist/modules/catalog.js.map +1 -1
- package/dist/modules/index.d.ts +2 -2
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/known-tools.d.ts +3 -0
- package/dist/modules/known-tools.d.ts.map +1 -1
- package/dist/modules/known-tools.js +11 -1
- package/dist/modules/known-tools.js.map +1 -1
- package/dist/modules/types.d.ts +20 -6
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/modules/types.js.map +1 -1
- package/dist/notify-runtime.d.ts +26 -0
- package/dist/notify-runtime.d.ts.map +1 -0
- package/dist/notify-runtime.js +27 -0
- package/dist/notify-runtime.js.map +1 -0
- package/dist/notify-tool.d.ts +51 -0
- package/dist/notify-tool.d.ts.map +1 -0
- package/dist/notify-tool.js +182 -0
- package/dist/notify-tool.js.map +1 -0
- package/dist/package-version.d.ts +3 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +14 -0
- package/dist/package-version.js.map +1 -0
- package/dist/pi-auth-store-inspection.d.ts +21 -0
- package/dist/pi-auth-store-inspection.d.ts.map +1 -0
- package/dist/pi-auth-store-inspection.js +120 -0
- package/dist/pi-auth-store-inspection.js.map +1 -0
- package/dist/pi-oauth-login-main.d.ts +3 -0
- package/dist/pi-oauth-login-main.d.ts.map +1 -0
- package/dist/pi-oauth-login-main.js +14 -0
- package/dist/pi-oauth-login-main.js.map +1 -0
- package/dist/pi-oauth-login.d.ts +22 -0
- package/dist/pi-oauth-login.d.ts.map +1 -0
- package/dist/pi-oauth-login.js +79 -0
- package/dist/pi-oauth-login.js.map +1 -0
- package/dist/proactive-notify.d.ts +2 -0
- package/dist/proactive-notify.d.ts.map +1 -1
- package/dist/proactive-notify.js +1 -0
- package/dist/proactive-notify.js.map +1 -1
- package/dist/process-incarnation.d.ts +23 -0
- package/dist/process-incarnation.d.ts.map +1 -0
- package/dist/process-incarnation.js +208 -0
- package/dist/process-incarnation.js.map +1 -0
- package/dist/project-skills.d.ts +40 -0
- package/dist/project-skills.d.ts.map +1 -0
- package/dist/project-skills.js +573 -0
- package/dist/project-skills.js.map +1 -0
- package/dist/provider-setup.d.ts +117 -2
- package/dist/provider-setup.d.ts.map +1 -1
- package/dist/provider-setup.js +1505 -49
- package/dist/provider-setup.js.map +1 -1
- package/dist/readiness-probe-worker.d.ts +12 -0
- package/dist/readiness-probe-worker.d.ts.map +1 -0
- package/dist/readiness-probe-worker.js +276 -0
- package/dist/readiness-probe-worker.js.map +1 -0
- package/dist/readiness-probe.d.ts +100 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +780 -0
- package/dist/readiness-probe.js.map +1 -0
- package/dist/recipes/base.d.ts +15 -0
- package/dist/recipes/base.d.ts.map +1 -0
- package/dist/recipes/base.js +51 -0
- package/dist/recipes/base.js.map +1 -0
- package/dist/recipes/catalog.d.ts +4 -0
- package/dist/recipes/catalog.d.ts.map +1 -0
- package/dist/recipes/catalog.js +525 -0
- package/dist/recipes/catalog.js.map +1 -0
- package/dist/recipes/index.d.ts +11 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +14 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/recipes/types.d.ts +70 -0
- package/dist/recipes/types.d.ts.map +1 -0
- package/dist/recipes/types.js +15 -0
- package/dist/recipes/types.js.map +1 -0
- package/dist/request-model-override.d.ts +43 -1
- package/dist/request-model-override.d.ts.map +1 -1
- package/dist/request-model-override.js +178 -19
- package/dist/request-model-override.js.map +1 -1
- package/dist/run-history.d.ts +32 -0
- package/dist/run-history.d.ts.map +1 -0
- package/dist/run-history.js +859 -0
- package/dist/run-history.js.map +1 -0
- package/dist/runs-health.d.ts.map +1 -1
- package/dist/runs-health.js +12 -4
- package/dist/runs-health.js.map +1 -1
- package/dist/runtime-option-extensions.d.ts +14 -0
- package/dist/runtime-option-extensions.d.ts.map +1 -0
- package/dist/runtime-option-extensions.js +98 -0
- package/dist/runtime-option-extensions.js.map +1 -0
- package/dist/runtime-routes.d.ts +9 -0
- package/dist/runtime-routes.d.ts.map +1 -0
- package/dist/runtime-routes.js +13 -0
- package/dist/runtime-routes.js.map +1 -0
- package/dist/sandbox-manager.d.ts +86 -0
- package/dist/sandbox-manager.d.ts.map +1 -0
- package/dist/sandbox-manager.js +1402 -0
- package/dist/sandbox-manager.js.map +1 -0
- package/dist/setup.d.ts +29 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +97 -0
- package/dist/setup.js.map +1 -0
- package/dist/supermemory-plugin.d.ts +56 -0
- package/dist/supermemory-plugin.d.ts.map +1 -0
- package/dist/supermemory-plugin.js +142 -0
- package/dist/supermemory-plugin.js.map +1 -0
- package/dist/tui-command.d.ts +56 -0
- package/dist/tui-command.d.ts.map +1 -1
- package/dist/tui-command.js +305 -31
- package/dist/tui-command.js.map +1 -1
- package/dist/web-command.d.ts +8 -0
- package/dist/web-command.d.ts.map +1 -1
- package/dist/web-command.js +174 -17
- package/dist/web-command.js.map +1 -1
- package/dist/wizard/answers.d.ts +31 -4
- package/dist/wizard/answers.d.ts.map +1 -1
- package/dist/wizard/answers.js +141 -29
- package/dist/wizard/answers.js.map +1 -1
- package/dist/wizard/from-flags.d.ts +7 -0
- package/dist/wizard/from-flags.d.ts.map +1 -1
- package/dist/wizard/from-flags.js +36 -2
- package/dist/wizard/from-flags.js.map +1 -1
- package/dist/wizard/index.d.ts +4 -4
- package/dist/wizard/index.d.ts.map +1 -1
- package/dist/wizard/index.js +2 -2
- package/dist/wizard/index.js.map +1 -1
- package/dist/wizard/model-discovery.d.ts +33 -2
- package/dist/wizard/model-discovery.d.ts.map +1 -1
- package/dist/wizard/model-discovery.js +826 -108
- package/dist/wizard/model-discovery.js.map +1 -1
- package/dist/wizard/presets.d.ts +1 -1
- package/dist/wizard/presets.d.ts.map +1 -1
- package/dist/wizard/presets.js +7 -12
- package/dist/wizard/presets.js.map +1 -1
- package/dist/wizard/prompts.d.ts +56 -9
- package/dist/wizard/prompts.d.ts.map +1 -1
- package/dist/wizard/prompts.js +172 -20
- package/dist/wizard/prompts.js.map +1 -1
- package/dist/wizard/run.d.ts +59 -3
- package/dist/wizard/run.d.ts.map +1 -1
- package/dist/wizard/run.js +1290 -191
- package/dist/wizard/run.js.map +1 -1
- package/package.json +21 -16
- package/resources/srt/package-lock.json +66 -0
- package/resources/srt/package.json +9 -0
- package/schema/mono-agent.config.schema.json +587 -20
- package/skills/mono-agent-composer/SKILL.md +6 -6
- package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
- package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
- package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
- package/skills/mono-agent-composer/references/package-map.md +2 -2
- package/skills/mono-agent-composer/references/playbooks.md +8 -7
- package/skills/mono-agent-composer/references/validation.md +3 -3
package/dist/init.js
CHANGED
|
@@ -1,22 +1,78 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { link, lstat, mkdir, open, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { parseEnv } from "node:util";
|
|
3
8
|
import { writeMonoAgentConfigJson } from "@mono-agent/config";
|
|
4
|
-
import { composeWizardPlan, defaultAnswers } from "./wizard/answers.js";
|
|
9
|
+
import { composeWizardPlan, defaultAnswers, humanizeAgentName } from "./wizard/answers.js";
|
|
10
|
+
import { initializeFirstRunManagedMemory, preflightFirstRunManagedMemory, } from "./first-run-managed-memory.js";
|
|
11
|
+
import { assertManagedProjectSkillInitSafe } from "./project-skills.js";
|
|
12
|
+
export class SecretEnvPersistenceRefusedError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "SecretEnvPersistenceRefusedError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class SecretEnvConcurrentModificationError extends Error {
|
|
21
|
+
ownerPid;
|
|
22
|
+
ownerCreatedAt;
|
|
23
|
+
recoveryPath;
|
|
24
|
+
constructor(path, owner, recoveryPath) {
|
|
25
|
+
super(recoveryPath !== undefined
|
|
26
|
+
? `Secret persistence detected a concurrent or interrupted change to ${path}; bytes were retained at ${recoveryPath}. Inspect both paths before retrying.`
|
|
27
|
+
: owner === undefined
|
|
28
|
+
? `Refusing to replace ${path} because it changed during secret persistence.`
|
|
29
|
+
: `Secret persistence for ${path} is already held by live PID ${owner.pid} since ${owner.createdAt}; retry after that process exits.`);
|
|
30
|
+
this.name = "SecretEnvConcurrentModificationError";
|
|
31
|
+
this.ownerPid = owner?.pid;
|
|
32
|
+
this.ownerCreatedAt = owner?.createdAt;
|
|
33
|
+
this.recoveryPath = recoveryPath;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
5
36
|
const KNOWLEDGE_FILE_CANDIDATES = ["AGENTS.md", "CLAUDE.md", "README.md", "SOUL.md"];
|
|
6
37
|
/**
|
|
7
38
|
* Non-destructively scaffolds a config-first mono-agent folder: a
|
|
8
39
|
* `mono-agent.config.json` composed from the wizard answers (default scaffold when
|
|
9
40
|
* none are supplied), an `IDENTITY.md` seeded from any knowledge files already in
|
|
10
41
|
* the folder, the `.mono-agent/` working directories, and — when the composed plan
|
|
11
|
-
* carries them — a `.env.example` and any capability files. Existing
|
|
12
|
-
* never overwritten
|
|
13
|
-
*
|
|
42
|
+
* carries them — a `.env.example` and any capability files. Existing
|
|
43
|
+
* scaffold/config files are never overwritten; reviewed secret persistence is
|
|
44
|
+
* the deliberate exception that can transactionally replace `.env` and update
|
|
45
|
+
* `.gitignore`. With `dryRun`, nothing is written and `created` reports what would
|
|
46
|
+
* have been.
|
|
14
47
|
*/
|
|
15
48
|
export async function initMonoAgentFolder(options = {}) {
|
|
16
49
|
const dir = resolve(options.dir ?? process.cwd());
|
|
17
50
|
const dryRun = options.dryRun === true;
|
|
51
|
+
const answers = options.answers ?? defaultAnswers();
|
|
18
52
|
const created = [];
|
|
19
53
|
const skipped = [];
|
|
54
|
+
const changes = [];
|
|
55
|
+
await assertManagedProjectSkillInitSafe(dir);
|
|
56
|
+
const ctx = {
|
|
57
|
+
dirBasename: basename(dir),
|
|
58
|
+
skillsRootExists: await pathExists(join(dir, "skills")),
|
|
59
|
+
};
|
|
60
|
+
const plan = composeWizardPlan(answers, ctx);
|
|
61
|
+
const configPath = join(dir, "mono-agent.config.json");
|
|
62
|
+
await assertSafeScaffoldTarget(configPath, "config");
|
|
63
|
+
const configAlreadyExists = await pathExists(configPath);
|
|
64
|
+
if (options.requireConfigCreation === true && configAlreadyExists) {
|
|
65
|
+
const error = new Error(`Refusing guided init because ${configPath} already exists.`);
|
|
66
|
+
error.code = "EEXIST";
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (!configAlreadyExists) {
|
|
70
|
+
await preflightFirstRunManagedMemory({
|
|
71
|
+
agentRoot: dir,
|
|
72
|
+
plan,
|
|
73
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
20
76
|
const knowledgeFiles = [];
|
|
21
77
|
for (const candidate of KNOWLEDGE_FILE_CANDIDATES) {
|
|
22
78
|
if (await pathExists(join(dir, candidate))) {
|
|
@@ -26,43 +82,1110 @@ export async function initMonoAgentFolder(options = {}) {
|
|
|
26
82
|
async function planFile(path, write) {
|
|
27
83
|
if (await pathExists(path)) {
|
|
28
84
|
skipped.push(path);
|
|
29
|
-
|
|
85
|
+
changes.push({ path, kind: "unchanged" });
|
|
86
|
+
return false;
|
|
30
87
|
}
|
|
31
88
|
if (!dryRun) {
|
|
32
89
|
await write();
|
|
33
90
|
}
|
|
34
91
|
created.push(path);
|
|
92
|
+
changes.push({ path, kind: dryRun ? "planned-create" : "created" });
|
|
93
|
+
return true;
|
|
35
94
|
}
|
|
36
95
|
const identityPath = join(dir, "IDENTITY.md");
|
|
37
|
-
await
|
|
96
|
+
await assertSafeScaffoldTarget(identityPath, "identity");
|
|
97
|
+
const identityCreated = await planFile(identityPath, () => writeFile(identityPath, identityTemplate(dir, answers.name?.trim() || humanizeAgentName(basename(dir)), answers.purpose?.trim() || "Help the operator work effectively in this folder.", knowledgeFiles), { flag: "wx" }));
|
|
38
98
|
for (const subdir of [join(dir, ".mono-agent", "artifacts"), join(dir, ".mono-agent", "workspace")]) {
|
|
39
|
-
await
|
|
99
|
+
await assertSafeScaffoldTarget(subdir, "working directory");
|
|
100
|
+
await ensureSafeScaffoldParent(dir, subdir, !dryRun);
|
|
101
|
+
await planFile(subdir, async () => {
|
|
102
|
+
await ensureSafeScaffoldParent(dir, subdir, true);
|
|
103
|
+
await createSafeScaffoldDirectory(subdir);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
let configCreatedByThisInit = false;
|
|
107
|
+
let configCreatedIdentity;
|
|
108
|
+
if (options.requireConfigCreation === true) {
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
if (await pathExists(configPath)) {
|
|
111
|
+
throw new Error(`Refusing guided init because ${configPath} already exists.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
await createMonoAgentConfigExclusively(configPath, plan.configJson);
|
|
116
|
+
}
|
|
117
|
+
created.push(configPath);
|
|
118
|
+
changes.push({ path: configPath, kind: dryRun ? "planned-create" : "created" });
|
|
119
|
+
configCreatedByThisInit = !dryRun;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const configPlanned = await planFile(configPath, () => writeMonoAgentConfigJson({ path: configPath, patch: plan.configJson }));
|
|
123
|
+
configCreatedByThisInit = !dryRun && configPlanned;
|
|
124
|
+
}
|
|
125
|
+
if (configCreatedByThisInit) {
|
|
126
|
+
const configStat = await lstat(configPath);
|
|
127
|
+
configCreatedIdentity = { dev: configStat.dev, ino: configStat.ino };
|
|
40
128
|
}
|
|
41
|
-
const ctx = {
|
|
42
|
-
dirBasename: basename(dir),
|
|
43
|
-
skillsRootExists: await pathExists(join(dir, "skills")),
|
|
44
|
-
};
|
|
45
|
-
const answers = options.answers ?? defaultAnswers();
|
|
46
|
-
const plan = composeWizardPlan(answers, ctx);
|
|
47
|
-
const configPath = join(dir, "mono-agent.config.json");
|
|
48
|
-
await planFile(configPath, () => writeMonoAgentConfigJson({ path: configPath, patch: plan.configJson }));
|
|
49
129
|
const envExample = plan.envExample;
|
|
50
130
|
if (typeof envExample === "string" && envExample.length > 0) {
|
|
51
131
|
const envExamplePath = join(dir, ".env.example");
|
|
132
|
+
await assertSafeScaffoldTarget(envExamplePath, "environment example");
|
|
52
133
|
await planFile(envExamplePath, () => writeFile(envExamplePath, envExample, { flag: "wx" }));
|
|
53
134
|
}
|
|
135
|
+
const requestedSecrets = options.secretValues !== undefined && Object.keys(options.secretValues).length > 0;
|
|
136
|
+
const requestedEnvSecurity = requestedSecrets || options.secureExistingDotenv === true;
|
|
137
|
+
let secretPersistence = { status: "not-requested", changed: false };
|
|
138
|
+
if (requestedEnvSecurity) {
|
|
139
|
+
const envPath = join(dir, ".env");
|
|
140
|
+
try {
|
|
141
|
+
const result = await mergeSecretEnvFile(envPath, options.secretValues ?? {}, {
|
|
142
|
+
dryRun,
|
|
143
|
+
secureExistingFile: options.secureExistingDotenv === true,
|
|
144
|
+
});
|
|
145
|
+
changes.push(...result.changes);
|
|
146
|
+
for (const change of result.changes) {
|
|
147
|
+
if (change.kind === "created" || change.kind === "planned-create")
|
|
148
|
+
created.push(change.path);
|
|
149
|
+
if (change.kind === "unchanged")
|
|
150
|
+
skipped.push(change.path);
|
|
151
|
+
}
|
|
152
|
+
secretPersistence = {
|
|
153
|
+
status: dryRun ? "planned" : "persisted",
|
|
154
|
+
path: envPath,
|
|
155
|
+
changed: result.valuesChanged > 0,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!(error instanceof SecretEnvPersistenceRefusedError))
|
|
160
|
+
throw error;
|
|
161
|
+
secretPersistence = {
|
|
162
|
+
status: "refused",
|
|
163
|
+
path: envPath,
|
|
164
|
+
changed: false,
|
|
165
|
+
reason: error.code,
|
|
166
|
+
detail: error.message,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
54
170
|
for (const file of plan.files) {
|
|
55
|
-
const filePath =
|
|
171
|
+
const filePath = resolve(dir, file.path);
|
|
172
|
+
await ensureSafeScaffoldParent(dir, filePath, !dryRun);
|
|
173
|
+
await assertSafeScaffoldTarget(filePath, `generated capability file ${file.path}`);
|
|
56
174
|
await planFile(filePath, async () => {
|
|
57
|
-
await
|
|
175
|
+
await ensureSafeScaffoldParent(dir, filePath, true);
|
|
58
176
|
await writeFile(filePath, file.contents, { flag: "wx" });
|
|
59
177
|
});
|
|
60
178
|
}
|
|
61
|
-
|
|
179
|
+
if (configCreatedByThisInit) {
|
|
180
|
+
try {
|
|
181
|
+
const initializedMemory = await initializeFirstRunManagedMemory({
|
|
182
|
+
agentRoot: dir,
|
|
183
|
+
plan,
|
|
184
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
185
|
+
...(options.firstRunManagedMemoryHooks === undefined
|
|
186
|
+
? {}
|
|
187
|
+
: { hooks: options.firstRunManagedMemoryHooks }),
|
|
188
|
+
});
|
|
189
|
+
if (initializedMemory.initialized && initializedMemory.root !== undefined) {
|
|
190
|
+
created.push(initializedMemory.root);
|
|
191
|
+
changes.push({ path: initializedMemory.root, kind: "created" });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (configCreatedIdentity !== undefined) {
|
|
196
|
+
await removeCreatedConfigIfUnchanged(configPath, configCreatedIdentity, `${JSON.stringify(plan.configJson, null, 2)}\n`);
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
dir,
|
|
203
|
+
configPath,
|
|
204
|
+
identityPath,
|
|
205
|
+
identityRole: {
|
|
206
|
+
path: identityPath,
|
|
207
|
+
section: "## Role",
|
|
208
|
+
status: identityCreated ? (dryRun ? "planned-create" : "created") : "preserved",
|
|
209
|
+
},
|
|
210
|
+
created,
|
|
211
|
+
skipped,
|
|
212
|
+
knowledgeFiles,
|
|
213
|
+
dryRun,
|
|
214
|
+
secretsPersisted: !dryRun && secretPersistence.status === "persisted" && secretPersistence.changed,
|
|
215
|
+
changes,
|
|
216
|
+
secretPersistence,
|
|
217
|
+
plan,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Securely merge required secrets into `.env` without replacing non-empty
|
|
222
|
+
* operator values. The destination and its git-ignore guard are preflighted,
|
|
223
|
+
* then each changed file is committed through an exclusive same-directory
|
|
224
|
+
* temporary file. The `.env` commit is always mode 0600 on supported systems.
|
|
225
|
+
*/
|
|
226
|
+
export async function mergeSecretEnvFile(path, secretValues, options = {}) {
|
|
227
|
+
const secretEntries = Object.entries(secretValues);
|
|
228
|
+
if (secretEntries.length === 0 && options.secureExistingFile !== true) {
|
|
229
|
+
return { changes: [], valuesChanged: 0 };
|
|
230
|
+
}
|
|
231
|
+
const platform = options.platform ?? process.platform;
|
|
232
|
+
const ownerOnlyPermissionsSupported = options.ownerOnlyPermissionsSupported ?? platform !== "win32";
|
|
233
|
+
if (!ownerOnlyPermissionsSupported) {
|
|
234
|
+
throw new SecretEnvPersistenceRefusedError("owner-only-permissions-unsupported", "Automatic secret persistence requires verifiable owner-only file permissions.");
|
|
235
|
+
}
|
|
236
|
+
const renderedValues = new Map();
|
|
237
|
+
for (const [name, value] of secretEntries) {
|
|
238
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
|
|
239
|
+
throw new SecretEnvPersistenceRefusedError("invalid-secret-name", `Automatic secret persistence refused an invalid environment variable name: ${name}.`);
|
|
240
|
+
}
|
|
241
|
+
renderedValues.set(name, renderDotenvValue(name, value));
|
|
242
|
+
}
|
|
243
|
+
const target = await resolveSafeSecretEnvTarget(path);
|
|
244
|
+
// A dry run has no commit to serialize and must remain write-free. Real
|
|
245
|
+
// persistence holds one same-directory lock across both snapshots, the git
|
|
246
|
+
// safety check, the ignore-file commit, and the env-file commit. Without this
|
|
247
|
+
// boundary two writers can read the same old .env, both pass the optimistic
|
|
248
|
+
// comparison, then rename over one another and silently lose one update.
|
|
249
|
+
return options.dryRun === true
|
|
250
|
+
? await mergeSecretEnvFileLocked(target.path, path, renderedValues, target.ownerUid, options)
|
|
251
|
+
: await withSecretEnvFileLock(target.path, target.ownerUid, () => mergeSecretEnvFileLocked(target.path, path, renderedValues, target.ownerUid, options));
|
|
252
|
+
}
|
|
253
|
+
async function mergeSecretEnvFileLocked(path, reportedPath, renderedValues, ownerUid, options) {
|
|
254
|
+
const envSnapshot = await readSafeFile(path, "unsafe-env-path", "malformed-env", ownerUid, [1], reportedPath);
|
|
255
|
+
if (await isGitTracked(path)) {
|
|
256
|
+
throw new SecretEnvPersistenceRefusedError("tracked-env", `Automatic secret persistence refused because ${path} is tracked by git.`);
|
|
257
|
+
}
|
|
258
|
+
const existingEnv = snapshotText(envSnapshot);
|
|
259
|
+
const merged = mergeDotenvContents(existingEnv, renderedValues);
|
|
260
|
+
const envContents = Buffer.from(merged.contents, "utf8");
|
|
261
|
+
const envNeedsWrite = !envSnapshot.exists || !envSnapshot.contents.equals(envContents) || fileMode(envSnapshot) !== 0o600;
|
|
262
|
+
const gitIgnorePath = join(dirname(path), ".gitignore");
|
|
263
|
+
const reportedGitIgnorePath = join(dirname(reportedPath), ".gitignore");
|
|
264
|
+
const gitIgnoreSnapshot = await readSafeFile(gitIgnorePath, "unsafe-gitignore-path", "malformed-gitignore", ownerUid, [1], reportedGitIgnorePath);
|
|
265
|
+
const gitIgnoreContents = Buffer.from(ensureExactEnvIgnore(snapshotText(gitIgnoreSnapshot)), "utf8");
|
|
266
|
+
const gitIgnoreMode = gitIgnoreSnapshot.exists
|
|
267
|
+
? (fileMode(gitIgnoreSnapshot) || 0o644) & ~0o022
|
|
268
|
+
: 0o644;
|
|
269
|
+
const gitIgnoreNeedsWrite = !gitIgnoreSnapshot.exists ||
|
|
270
|
+
!gitIgnoreSnapshot.contents.equals(gitIgnoreContents) ||
|
|
271
|
+
fileMode(gitIgnoreSnapshot) !== gitIgnoreMode;
|
|
272
|
+
const changes = [
|
|
273
|
+
changeFor(reportedGitIgnorePath, gitIgnoreSnapshot.exists, gitIgnoreNeedsWrite, options.dryRun === true),
|
|
274
|
+
{ ...changeFor(reportedPath, envSnapshot.exists, envNeedsWrite, options.dryRun === true), sensitive: true },
|
|
275
|
+
];
|
|
276
|
+
if (options.dryRun === true) {
|
|
277
|
+
return { changes, valuesChanged: merged.valuesChanged };
|
|
278
|
+
}
|
|
279
|
+
if (gitIgnoreNeedsWrite) {
|
|
280
|
+
await atomicReplaceFile({
|
|
281
|
+
path: gitIgnorePath,
|
|
282
|
+
reportedPath: reportedGitIgnorePath,
|
|
283
|
+
contents: gitIgnoreContents,
|
|
284
|
+
expected: gitIgnoreSnapshot,
|
|
285
|
+
ownerUid,
|
|
286
|
+
mode: gitIgnoreMode,
|
|
287
|
+
beforeCommit: options.beforeCommit,
|
|
288
|
+
beforePromotion: options.beforePromotion,
|
|
289
|
+
beforeInstallLink: options.beforeInstallLink,
|
|
290
|
+
afterInstallLink: options.afterInstallLink,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const committedGitIgnoreSnapshot = gitIgnoreNeedsWrite
|
|
294
|
+
? await readSafeFile(gitIgnorePath, "unsafe-gitignore-path", "malformed-gitignore", ownerUid, [1], reportedGitIgnorePath)
|
|
295
|
+
: gitIgnoreSnapshot;
|
|
296
|
+
if (!committedGitIgnoreSnapshot.exists ||
|
|
297
|
+
!committedGitIgnoreSnapshot.contents.equals(gitIgnoreContents)) {
|
|
298
|
+
throw new SecretEnvConcurrentModificationError(gitIgnorePath);
|
|
299
|
+
}
|
|
300
|
+
if (envNeedsWrite) {
|
|
301
|
+
await atomicReplaceFile({
|
|
302
|
+
path,
|
|
303
|
+
reportedPath,
|
|
304
|
+
contents: envContents,
|
|
305
|
+
expected: envSnapshot,
|
|
306
|
+
ownerUid,
|
|
307
|
+
mode: 0o600,
|
|
308
|
+
verifyOwnerOnly: true,
|
|
309
|
+
beforeCommit: options.beforeCommit,
|
|
310
|
+
beforeRename: async () => {
|
|
311
|
+
if (await isGitTracked(path)) {
|
|
312
|
+
throw new SecretEnvPersistenceRefusedError("tracked-env", `Automatic secret persistence refused because ${path} became tracked by git before commit.`);
|
|
313
|
+
}
|
|
314
|
+
await assertSnapshotUnchanged(gitIgnorePath, reportedGitIgnorePath, committedGitIgnoreSnapshot, ownerUid);
|
|
315
|
+
},
|
|
316
|
+
beforePromotion: options.beforePromotion,
|
|
317
|
+
beforeInstallLink: options.beforeInstallLink,
|
|
318
|
+
afterInstallLink: options.afterInstallLink,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return { changes, valuesChanged: merged.valuesChanged };
|
|
322
|
+
}
|
|
323
|
+
async function withSecretEnvFileLock(path, ownerUid, task) {
|
|
324
|
+
const lockPath = await secretEnvLockPathForCanonical(path, ownerUid);
|
|
325
|
+
const owner = {
|
|
326
|
+
version: 1,
|
|
327
|
+
pid: process.pid,
|
|
328
|
+
createdAt: new Date().toISOString(),
|
|
329
|
+
ownerUid,
|
|
330
|
+
token: randomUUID(),
|
|
331
|
+
};
|
|
332
|
+
const lock = await acquireSecretEnvLock(path, lockPath, owner);
|
|
333
|
+
try {
|
|
334
|
+
return await task();
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
try {
|
|
338
|
+
await lock.handle.close();
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
await releaseSecretEnvLock(lockPath, lock);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const MAX_SECRET_ENV_LOCK_BYTES = 4096;
|
|
346
|
+
const MAX_SECRET_ENV_LOCK_ACQUIRE_ATTEMPTS = 4;
|
|
347
|
+
async function acquireSecretEnvLock(envPath, lockPath, owner) {
|
|
348
|
+
for (let attempt = 0; attempt < MAX_SECRET_ENV_LOCK_ACQUIRE_ATTEMPTS; attempt += 1) {
|
|
349
|
+
let handle;
|
|
350
|
+
try {
|
|
351
|
+
handle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
const code = error.code;
|
|
355
|
+
if (code !== "EEXIST" && code !== "ELOOP")
|
|
356
|
+
throw error;
|
|
357
|
+
let existing;
|
|
358
|
+
try {
|
|
359
|
+
existing = await readSecretEnvLockSnapshot(lockPath, owner.ownerUid);
|
|
360
|
+
}
|
|
361
|
+
catch (readError) {
|
|
362
|
+
// O_EXCL publishes the directory entry just before its owner record is
|
|
363
|
+
// written. Give that tiny creation window a bounded retry so a real
|
|
364
|
+
// concurrent owner is reported as live instead of as malformed.
|
|
365
|
+
if (readError instanceof SecretEnvPersistenceRefusedError &&
|
|
366
|
+
readError.code === "unsafe-lock-path" &&
|
|
367
|
+
attempt + 1 < MAX_SECRET_ENV_LOCK_ACQUIRE_ATTEMPTS) {
|
|
368
|
+
await new Promise((resolveRetry) => setTimeout(resolveRetry, 10));
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
throw readError;
|
|
372
|
+
}
|
|
373
|
+
if (existing === undefined)
|
|
374
|
+
continue;
|
|
375
|
+
const ownerState = secretEnvLockOwnerState(existing.owner.pid);
|
|
376
|
+
if (ownerState === "live") {
|
|
377
|
+
throw new SecretEnvConcurrentModificationError(envPath, existing.owner);
|
|
378
|
+
}
|
|
379
|
+
if (ownerState === "unknown") {
|
|
380
|
+
throw unsafeSecretEnvLockError(lockPath, `the owner process state for PID ${existing.owner.pid} could not be verified`);
|
|
381
|
+
}
|
|
382
|
+
throw unsafeSecretEnvLockError(lockPath, `the recorded owner PID ${existing.owner.pid} is no longer running`);
|
|
383
|
+
}
|
|
384
|
+
const contents = Buffer.from(`${JSON.stringify(owner)}\n`, "utf8");
|
|
385
|
+
let identity;
|
|
386
|
+
try {
|
|
387
|
+
const initialStat = await handle.stat();
|
|
388
|
+
identity = { dev: initialStat.dev, ino: initialStat.ino };
|
|
389
|
+
assertSecretEnvLockFileStat(lockPath, initialStat, owner.ownerUid);
|
|
390
|
+
await handle.writeFile(contents);
|
|
391
|
+
await handle.sync();
|
|
392
|
+
const writtenStat = await handle.stat();
|
|
393
|
+
assertSecretEnvLockFileStat(lockPath, writtenStat, owner.ownerUid);
|
|
394
|
+
if (writtenStat.dev !== initialStat.dev ||
|
|
395
|
+
writtenStat.ino !== initialStat.ino ||
|
|
396
|
+
writtenStat.size !== contents.length) {
|
|
397
|
+
throw unsafeSecretEnvLockError(lockPath, "the durable owner record changed while it was being created");
|
|
398
|
+
}
|
|
399
|
+
await syncDirectoryBestEffort(dirname(lockPath));
|
|
400
|
+
return {
|
|
401
|
+
handle,
|
|
402
|
+
dev: writtenStat.dev,
|
|
403
|
+
ino: writtenStat.ino,
|
|
404
|
+
contents,
|
|
405
|
+
owner,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
try {
|
|
410
|
+
await handle.close();
|
|
411
|
+
}
|
|
412
|
+
finally {
|
|
413
|
+
if (identity !== undefined) {
|
|
414
|
+
await removeSecretEnvLockWithIdentity(lockPath, identity);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
throw new SecretEnvConcurrentModificationError(envPath);
|
|
421
|
+
}
|
|
422
|
+
function currentProcessUid(lockPath) {
|
|
423
|
+
if (typeof process.getuid !== "function") {
|
|
424
|
+
throw unsafeSecretEnvLockError(lockPath, "the current operating-system user id is unavailable");
|
|
425
|
+
}
|
|
426
|
+
return process.getuid();
|
|
427
|
+
}
|
|
428
|
+
function assertSecretEnvLockFileStat(lockPath, lockStat, ownerUid) {
|
|
429
|
+
if (!lockStat.isFile() ||
|
|
430
|
+
lockStat.uid !== ownerUid ||
|
|
431
|
+
(lockStat.mode & 0o777) !== 0o600 ||
|
|
432
|
+
lockStat.nlink !== 1) {
|
|
433
|
+
throw unsafeSecretEnvLockError(lockPath, "it is not a same-user, owner-only regular file with one link");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function readSecretEnvLockSnapshot(lockPath, ownerUid) {
|
|
437
|
+
let pathStat;
|
|
438
|
+
try {
|
|
439
|
+
pathStat = await lstat(lockPath);
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
if (error.code === "ENOENT")
|
|
443
|
+
return undefined;
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
if (pathStat.isSymbolicLink() ||
|
|
447
|
+
!pathStat.isFile() ||
|
|
448
|
+
pathStat.uid !== ownerUid ||
|
|
449
|
+
(pathStat.mode & 0o777) !== 0o600 ||
|
|
450
|
+
pathStat.nlink !== 1) {
|
|
451
|
+
throw unsafeSecretEnvLockError(lockPath, "same-user ownership and owner-only regular-file identity could not be proven");
|
|
452
|
+
}
|
|
453
|
+
if (pathStat.size <= 0 || pathStat.size > MAX_SECRET_ENV_LOCK_BYTES) {
|
|
454
|
+
throw unsafeSecretEnvLockError(lockPath, "the durable owner record has an invalid size");
|
|
455
|
+
}
|
|
456
|
+
let handle;
|
|
457
|
+
try {
|
|
458
|
+
handle = await open(lockPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
459
|
+
const handleStat = await handle.stat();
|
|
460
|
+
assertSecretEnvLockFileStat(lockPath, handleStat, ownerUid);
|
|
461
|
+
if (handleStat.dev !== pathStat.dev || handleStat.ino !== pathStat.ino)
|
|
462
|
+
return undefined;
|
|
463
|
+
const contents = await handle.readFile();
|
|
464
|
+
if (contents.length !== handleStat.size)
|
|
465
|
+
return undefined;
|
|
466
|
+
const owner = parseSecretEnvLockOwner(lockPath, contents, ownerUid);
|
|
467
|
+
return { dev: handleStat.dev, ino: handleStat.ino, contents, owner };
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (error.code === "ENOENT")
|
|
471
|
+
return undefined;
|
|
472
|
+
if (error.code === "ELOOP") {
|
|
473
|
+
throw unsafeSecretEnvLockError(lockPath, "the lock path became a symbolic link");
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
await handle?.close();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function parseSecretEnvLockOwner(lockPath, contents, ownerUid) {
|
|
482
|
+
let parsed;
|
|
483
|
+
try {
|
|
484
|
+
parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(contents));
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
throw unsafeSecretEnvLockError(lockPath, "the durable owner record is not valid UTF-8 JSON");
|
|
488
|
+
}
|
|
489
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
490
|
+
throw unsafeSecretEnvLockError(lockPath, "the durable owner record is not an object");
|
|
491
|
+
}
|
|
492
|
+
const record = parsed;
|
|
493
|
+
const createdAtMs = typeof record.createdAt === "string" ? Date.parse(record.createdAt) : Number.NaN;
|
|
494
|
+
if (record.version !== 1 ||
|
|
495
|
+
!Number.isInteger(record.pid) ||
|
|
496
|
+
record.pid <= 0 ||
|
|
497
|
+
typeof record.createdAt !== "string" ||
|
|
498
|
+
record.createdAt.length > 64 ||
|
|
499
|
+
!Number.isFinite(createdAtMs) ||
|
|
500
|
+
record.ownerUid !== ownerUid ||
|
|
501
|
+
typeof record.token !== "string" ||
|
|
502
|
+
record.token.length === 0 ||
|
|
503
|
+
record.token.length > 128) {
|
|
504
|
+
throw unsafeSecretEnvLockError(lockPath, "the durable owner record is malformed or belongs to another user");
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
version: 1,
|
|
508
|
+
pid: record.pid,
|
|
509
|
+
createdAt: record.createdAt,
|
|
510
|
+
ownerUid,
|
|
511
|
+
token: record.token,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function secretEnvLockOwnerState(pid) {
|
|
515
|
+
try {
|
|
516
|
+
process.kill(pid, 0);
|
|
517
|
+
return "live";
|
|
518
|
+
}
|
|
519
|
+
catch (error) {
|
|
520
|
+
const code = error.code;
|
|
521
|
+
if (code === "ESRCH")
|
|
522
|
+
return "dead";
|
|
523
|
+
return "unknown";
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
async function releaseSecretEnvLock(lockPath, expected) {
|
|
527
|
+
let current;
|
|
528
|
+
try {
|
|
529
|
+
current = await readSecretEnvLockSnapshot(lockPath, expected.owner.ownerUid);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Never remove a path whose ownership changed while this process held the
|
|
533
|
+
// original descriptor; it may now be another process's live lock.
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (current === undefined || !sameSecretEnvLockSnapshot(current, expected))
|
|
537
|
+
return;
|
|
538
|
+
try {
|
|
539
|
+
await rm(lockPath);
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (error.code !== "ENOENT")
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
await syncDirectoryBestEffort(dirname(lockPath));
|
|
546
|
+
}
|
|
547
|
+
async function resolveSafeSecretEnvTarget(path) {
|
|
548
|
+
const canonicalParent = await realpath(dirname(path));
|
|
549
|
+
const ownerUid = currentProcessUid(canonicalParent);
|
|
550
|
+
const pathStat = await lstat(canonicalParent);
|
|
551
|
+
let handle;
|
|
552
|
+
try {
|
|
553
|
+
handle = await open(canonicalParent, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
554
|
+
const handleStat = await handle.stat();
|
|
555
|
+
if (pathStat.isSymbolicLink() ||
|
|
556
|
+
!pathStat.isDirectory() ||
|
|
557
|
+
!handleStat.isDirectory() ||
|
|
558
|
+
pathStat.dev !== handleStat.dev ||
|
|
559
|
+
pathStat.ino !== handleStat.ino ||
|
|
560
|
+
handleStat.uid !== ownerUid ||
|
|
561
|
+
(handleStat.mode & 0o022) !== 0) {
|
|
562
|
+
throw new SecretEnvPersistenceRefusedError("unsafe-env-path", `Automatic secret persistence requires parent directory ${canonicalParent} to be owned by the current user and not group/world-writable.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
await handle?.close();
|
|
567
|
+
}
|
|
568
|
+
return { path: join(canonicalParent, basename(path)), ownerUid };
|
|
569
|
+
}
|
|
570
|
+
/** Stable, repository-external lock path for one canonical dotenv target. */
|
|
571
|
+
export async function secretEnvLockPathFor(path) {
|
|
572
|
+
const target = await resolveSafeSecretEnvTarget(path);
|
|
573
|
+
return await secretEnvLockPathForCanonical(target.path, target.ownerUid);
|
|
574
|
+
}
|
|
575
|
+
async function secretEnvLockPathForCanonical(path, ownerUid) {
|
|
576
|
+
const root = join(tmpdir(), `mono-agent-secret-env-locks-${ownerUid}`);
|
|
577
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
578
|
+
const rootStat = await lstat(root);
|
|
579
|
+
if (rootStat.isSymbolicLink() ||
|
|
580
|
+
!rootStat.isDirectory() ||
|
|
581
|
+
rootStat.uid !== ownerUid ||
|
|
582
|
+
(rootStat.mode & 0o077) !== 0) {
|
|
583
|
+
throw unsafeSecretEnvLockError(root, "the repository-external lock directory is not private to the current user");
|
|
584
|
+
}
|
|
585
|
+
const key = createHash("sha256")
|
|
586
|
+
.update(dirname(path))
|
|
587
|
+
.update("\0")
|
|
588
|
+
.update(basename(path))
|
|
589
|
+
.digest("hex");
|
|
590
|
+
return join(root, `${key}.lock`);
|
|
591
|
+
}
|
|
592
|
+
function sameSecretEnvLockSnapshot(left, right) {
|
|
593
|
+
return left.dev === right.dev &&
|
|
594
|
+
left.ino === right.ino &&
|
|
595
|
+
left.owner.token === right.owner.token &&
|
|
596
|
+
left.contents.equals(right.contents);
|
|
597
|
+
}
|
|
598
|
+
async function removeSecretEnvLockWithIdentity(lockPath, expected) {
|
|
599
|
+
try {
|
|
600
|
+
const current = await lstat(lockPath);
|
|
601
|
+
if (current.dev !== expected.dev || current.ino !== expected.ino)
|
|
602
|
+
return;
|
|
603
|
+
await rm(lockPath);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
if (error.code !== "ENOENT")
|
|
607
|
+
throw error;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function unsafeSecretEnvLockError(lockPath, detail) {
|
|
611
|
+
return new SecretEnvPersistenceRefusedError("unsafe-lock-path", `Automatic secret persistence cannot safely use lock ${lockPath}: ${detail}. Verify no mono-agent init is running, then remove the stale lock manually and retry.`);
|
|
612
|
+
}
|
|
613
|
+
async function readSafeFile(path, unsafeCode, malformedCode, ownerUid, allowedLinkCounts = [1], reportedPath = path) {
|
|
614
|
+
let pathStat;
|
|
615
|
+
try {
|
|
616
|
+
pathStat = await lstat(path);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
if (error.code === "ENOENT")
|
|
620
|
+
return { exists: false };
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
assertSafeSecretFileStat(reportedPath, pathStat, unsafeCode, ownerUid, allowedLinkCounts);
|
|
624
|
+
let handle;
|
|
625
|
+
try {
|
|
626
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
627
|
+
const handleStat = await handle.stat();
|
|
628
|
+
assertSafeSecretFileStat(reportedPath, handleStat, unsafeCode, ownerUid, allowedLinkCounts);
|
|
629
|
+
if (handleStat.dev !== pathStat.dev || handleStat.ino !== pathStat.ino) {
|
|
630
|
+
throw new SecretEnvConcurrentModificationError(reportedPath);
|
|
631
|
+
}
|
|
632
|
+
const contents = await handle.readFile();
|
|
633
|
+
decodeUtf8(contents, reportedPath, malformedCode);
|
|
634
|
+
const finalPathStat = await lstat(path);
|
|
635
|
+
assertSafeSecretFileStat(reportedPath, finalPathStat, unsafeCode, ownerUid, allowedLinkCounts);
|
|
636
|
+
if (finalPathStat.dev !== handleStat.dev || finalPathStat.ino !== handleStat.ino) {
|
|
637
|
+
throw new SecretEnvConcurrentModificationError(reportedPath);
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
exists: true,
|
|
641
|
+
contents,
|
|
642
|
+
mode: handleStat.mode,
|
|
643
|
+
dev: handleStat.dev,
|
|
644
|
+
ino: handleStat.ino,
|
|
645
|
+
uid: handleStat.uid,
|
|
646
|
+
nlink: handleStat.nlink,
|
|
647
|
+
size: handleStat.size,
|
|
648
|
+
mtimeMs: handleStat.mtimeMs,
|
|
649
|
+
ctimeMs: handleStat.ctimeMs,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (error.code === "ELOOP") {
|
|
654
|
+
throw new SecretEnvPersistenceRefusedError(unsafeCode, `Automatic secret persistence refused unsafe path ${reportedPath}.`);
|
|
655
|
+
}
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
await handle?.close();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function assertSafeSecretFileStat(path, value, unsafeCode, ownerUid, allowedLinkCounts) {
|
|
663
|
+
if (value.isSymbolicLink() || !value.isFile()) {
|
|
664
|
+
throw new SecretEnvPersistenceRefusedError(unsafeCode, `Automatic secret persistence refused unsafe path ${path}.`);
|
|
665
|
+
}
|
|
666
|
+
if (value.uid !== ownerUid) {
|
|
667
|
+
throw new SecretEnvPersistenceRefusedError(unsafeCode, `Automatic secret persistence refused ${path} because it is not owned by the current user.`);
|
|
668
|
+
}
|
|
669
|
+
if (!allowedLinkCounts.includes(value.nlink)) {
|
|
670
|
+
throw new SecretEnvPersistenceRefusedError(unsafeCode, `Automatic secret persistence refused ${path} because its hard-link identity is unsafe.`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function snapshotText(snapshot) {
|
|
674
|
+
if (!snapshot.exists)
|
|
675
|
+
return "";
|
|
676
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(snapshot.contents);
|
|
677
|
+
}
|
|
678
|
+
function decodeUtf8(contents, path, code) {
|
|
679
|
+
try {
|
|
680
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(contents);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
throw new SecretEnvPersistenceRefusedError(code, `Automatic secret persistence requires UTF-8 text at ${path}.`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function fileMode(snapshot) {
|
|
687
|
+
return snapshot.exists ? snapshot.mode & 0o777 : 0;
|
|
688
|
+
}
|
|
689
|
+
function changeFor(path, exists, needsWrite, dryRun) {
|
|
690
|
+
if (!needsWrite)
|
|
691
|
+
return { path, kind: "unchanged" };
|
|
692
|
+
if (dryRun)
|
|
693
|
+
return { path, kind: exists ? "planned-update" : "planned-create" };
|
|
694
|
+
return { path, kind: exists ? "updated" : "created" };
|
|
695
|
+
}
|
|
696
|
+
function renderDotenvValue(name, value) {
|
|
697
|
+
if (value.length === 0 || value.includes("\0")) {
|
|
698
|
+
throw new SecretEnvPersistenceRefusedError("unrepresentable-secret-value", `Automatic secret persistence requires a non-empty dotenv-safe value for ${name}.`);
|
|
699
|
+
}
|
|
700
|
+
const candidates = [
|
|
701
|
+
...(value.includes("'") ? [] : [`'${value}'`]),
|
|
702
|
+
...(value.includes('"') ? [] : [`"${value}"`]),
|
|
703
|
+
value,
|
|
704
|
+
];
|
|
705
|
+
for (const candidate of candidates) {
|
|
706
|
+
try {
|
|
707
|
+
if (parseEnv(`${name}=${candidate}\n`)[name] === value)
|
|
708
|
+
return candidate;
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// Try the next representation. Some values have no lossless dotenv form.
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
throw new SecretEnvPersistenceRefusedError("unrepresentable-secret-value", `Automatic secret persistence cannot represent the supplied value for ${name} losslessly.`);
|
|
715
|
+
}
|
|
716
|
+
function mergeDotenvContents(existing, renderedValues) {
|
|
717
|
+
let parsed;
|
|
718
|
+
try {
|
|
719
|
+
parsed = parseEnv(existing);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
throw new SecretEnvPersistenceRefusedError("malformed-env", "Automatic secret persistence refused a malformed .env file.");
|
|
723
|
+
}
|
|
724
|
+
const lines = splitLinesPreservingEndings(existing);
|
|
725
|
+
const preferredNewline = lines.find((line) => line.ending.length > 0)?.ending ?? "\n";
|
|
726
|
+
let valuesChanged = 0;
|
|
727
|
+
for (const [name, renderedValue] of renderedValues) {
|
|
728
|
+
if ((parsed[name] ?? "").length > 0)
|
|
729
|
+
continue;
|
|
730
|
+
const matcher = new RegExp(`^(\\s*(?:export\\s+)?${escapeRegExp(name)}\\s*=)(.*)$`, "u");
|
|
731
|
+
let index = -1;
|
|
732
|
+
for (let candidate = lines.length - 1; candidate >= 0; candidate -= 1) {
|
|
733
|
+
const body = lines[candidate]?.body ?? "";
|
|
734
|
+
const assignment = matcher.exec(body);
|
|
735
|
+
if (assignment === null)
|
|
736
|
+
continue;
|
|
737
|
+
const placeholder = /^\s*(?:''|"")?\s*(#.*)?$/u.exec(assignment[2] ?? "");
|
|
738
|
+
if (placeholder === null)
|
|
739
|
+
continue;
|
|
740
|
+
const marker = `__MONO_AGENT_EMPTY_${name}__`;
|
|
741
|
+
const markedLines = lines.map((line, lineIndex) => lineIndex === candidate
|
|
742
|
+
? { ...line, body: `${assignment[1]}'${marker}'${placeholder[1] === undefined ? "" : ` ${placeholder[1]}`}` }
|
|
743
|
+
: line);
|
|
744
|
+
try {
|
|
745
|
+
if (parseEnv(joinPreservedLines(markedLines))[name] === marker) {
|
|
746
|
+
index = candidate;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
// The full file validation below reports malformed input without writing.
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (index === -1) {
|
|
755
|
+
if (parsed[name] !== undefined) {
|
|
756
|
+
throw new SecretEnvPersistenceRefusedError("malformed-env", `Automatic secret persistence could not safely replace the empty placeholder for ${name}.`);
|
|
757
|
+
}
|
|
758
|
+
const last = lines[lines.length - 1];
|
|
759
|
+
if (last !== undefined && last.ending.length === 0)
|
|
760
|
+
last.ending = preferredNewline;
|
|
761
|
+
lines.push({ body: `${name}=${renderedValue}`, ending: preferredNewline });
|
|
762
|
+
valuesChanged += 1;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const currentLine = lines[index];
|
|
766
|
+
const assignment = matcher.exec(currentLine?.body ?? "");
|
|
767
|
+
const placeholder = /^\s*(?:''|"")?\s*(#.*)?$/u.exec(assignment?.[2] ?? "");
|
|
768
|
+
if (currentLine === undefined || assignment === null || placeholder === null) {
|
|
769
|
+
throw new SecretEnvPersistenceRefusedError("malformed-env", `Automatic secret persistence could not safely replace the empty placeholder for ${name}.`);
|
|
770
|
+
}
|
|
771
|
+
const comment = placeholder[1];
|
|
772
|
+
currentLine.body = `${assignment[1]}${renderedValue}${comment === undefined ? "" : ` ${comment}`}`;
|
|
773
|
+
valuesChanged += 1;
|
|
774
|
+
}
|
|
775
|
+
return { contents: joinPreservedLines(lines), valuesChanged };
|
|
776
|
+
}
|
|
777
|
+
function splitLinesPreservingEndings(value) {
|
|
778
|
+
if (value.length === 0)
|
|
779
|
+
return [];
|
|
780
|
+
const lines = [];
|
|
781
|
+
const endings = /\r\n|\n|\r/gu;
|
|
782
|
+
let cursor = 0;
|
|
783
|
+
for (const match of value.matchAll(endings)) {
|
|
784
|
+
const index = match.index;
|
|
785
|
+
lines.push({ body: value.slice(cursor, index), ending: match[0] });
|
|
786
|
+
cursor = index + match[0].length;
|
|
787
|
+
}
|
|
788
|
+
if (cursor < value.length)
|
|
789
|
+
lines.push({ body: value.slice(cursor), ending: "" });
|
|
790
|
+
return lines;
|
|
791
|
+
}
|
|
792
|
+
function joinPreservedLines(lines) {
|
|
793
|
+
return lines.map((line) => `${line.body}${line.ending}`).join("");
|
|
794
|
+
}
|
|
795
|
+
const SECRET_ENV_IGNORE_RULES = [
|
|
796
|
+
"/.env",
|
|
797
|
+
"/..env.mono-agent-*.tmp",
|
|
798
|
+
"/.env.mono-agent-*.backup",
|
|
799
|
+
];
|
|
800
|
+
function ensureExactEnvIgnore(existing) {
|
|
801
|
+
const lines = existing.length === 0 ? [] : existing.split(/\r?\n/u);
|
|
802
|
+
// Keep the whole secret-file transaction block as the final effective rules.
|
|
803
|
+
// An earlier rule can be cancelled by a later negation, while the temporary
|
|
804
|
+
// and rollback names can contain the same secret bytes as the final .env.
|
|
805
|
+
const meaningful = [];
|
|
806
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
807
|
+
const trimmed = (lines[index] ?? "").trim();
|
|
808
|
+
if (trimmed.length === 0 || trimmed.startsWith("#"))
|
|
809
|
+
continue;
|
|
810
|
+
meaningful.push(trimmed);
|
|
811
|
+
}
|
|
812
|
+
if (meaningful.length >= SECRET_ENV_IGNORE_RULES.length &&
|
|
813
|
+
SECRET_ENV_IGNORE_RULES.every((rule, index) => meaningful[meaningful.length - SECRET_ENV_IGNORE_RULES.length + index] === rule))
|
|
814
|
+
return existing;
|
|
815
|
+
const newline = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
816
|
+
return `${existing}${existing.length > 0 && !existing.endsWith("\n") ? newline : ""}` +
|
|
817
|
+
`${SECRET_ENV_IGNORE_RULES.join(newline)}${newline}`;
|
|
818
|
+
}
|
|
819
|
+
async function atomicReplaceFile(options) {
|
|
820
|
+
const temporaryPath = join(dirname(options.path), `.${basename(options.path)}.mono-agent-${randomUUID()}.tmp`);
|
|
821
|
+
let handle;
|
|
822
|
+
try {
|
|
823
|
+
handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
824
|
+
await handle.writeFile(options.contents);
|
|
825
|
+
await handle.chmod(options.mode);
|
|
826
|
+
const tempStat = await handle.stat();
|
|
827
|
+
if (options.verifyOwnerOnly === true && (tempStat.mode & 0o777) !== 0o600) {
|
|
828
|
+
throw new SecretEnvPersistenceRefusedError("owner-only-permissions-unsupported", "Automatic secret persistence could not verify owner-only permissions.");
|
|
829
|
+
}
|
|
830
|
+
await handle.sync();
|
|
831
|
+
await handle.close();
|
|
832
|
+
handle = undefined;
|
|
833
|
+
await options.beforeCommit?.(options.reportedPath, temporaryPath);
|
|
834
|
+
await assertSnapshotUnchanged(options.path, options.reportedPath, options.expected, options.ownerUid);
|
|
835
|
+
await options.beforeRename?.();
|
|
836
|
+
// beforeRename may perform async Git and cross-file checks. Re-read the
|
|
837
|
+
// target afterwards so an editor cannot win that widened window silently.
|
|
838
|
+
await assertSnapshotUnchanged(options.path, options.reportedPath, options.expected, options.ownerUid);
|
|
839
|
+
await options.beforePromotion?.(options.reportedPath, temporaryPath);
|
|
840
|
+
await promoteTemporaryFileWithoutClobber(options, temporaryPath);
|
|
841
|
+
await syncDirectoryBestEffort(dirname(options.path));
|
|
842
|
+
}
|
|
843
|
+
finally {
|
|
844
|
+
await handle?.close();
|
|
845
|
+
await rm(temporaryPath, { force: true });
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
async function promoteTemporaryFileWithoutClobber(options, temporaryPath) {
|
|
849
|
+
if (!options.expected.exists) {
|
|
850
|
+
if (!await linkIfAbsent(temporaryPath, options.path)) {
|
|
851
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath);
|
|
852
|
+
}
|
|
853
|
+
await assertPromotedContents(options, temporaryPath);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const backupPath = join(dirname(options.path), `${basename(options.path)}.mono-agent-${randomUUID()}.backup`);
|
|
857
|
+
let backupContainsExpected = false;
|
|
858
|
+
// Once target is moved, this backup is the only durable copy until a target
|
|
859
|
+
// is proven present. Preserve it by default on every unexpected failure.
|
|
860
|
+
let preserveConcurrentBackup = true;
|
|
861
|
+
try {
|
|
862
|
+
try {
|
|
863
|
+
// Claim whichever inode occupies the pathname at this exact instant.
|
|
864
|
+
// A path-based writer racing after this point creates a new target that
|
|
865
|
+
// the exclusive link below will preserve rather than overwrite.
|
|
866
|
+
await rename(options.path, backupPath);
|
|
867
|
+
}
|
|
868
|
+
catch (error) {
|
|
869
|
+
if (error.code === "ENOENT") {
|
|
870
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath);
|
|
871
|
+
}
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
let moved;
|
|
875
|
+
try {
|
|
876
|
+
moved = await readSafeFile(backupPath, basename(options.path) === ".gitignore" ? "unsafe-gitignore-path" : "unsafe-env-path", basename(options.path) === ".gitignore" ? "malformed-gitignore" : "malformed-env", options.ownerUid);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
880
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
881
|
+
}
|
|
882
|
+
backupContainsExpected = moved.exists && sameMovedFileSnapshot(moved, options.expected);
|
|
883
|
+
if (!backupContainsExpected) {
|
|
884
|
+
// We claimed a newer writer's file. Restore it without replacing any
|
|
885
|
+
// still-newer target; if restoration loses that race, retain a private,
|
|
886
|
+
// ignored recovery copy and fail closed.
|
|
887
|
+
try {
|
|
888
|
+
if (await linkIfAbsent(backupPath, options.path)) {
|
|
889
|
+
await rm(backupPath, { force: true });
|
|
890
|
+
preserveConcurrentBackup = false;
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
898
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
899
|
+
}
|
|
900
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, preserveConcurrentBackup ? backupPath : undefined);
|
|
901
|
+
}
|
|
902
|
+
let installed;
|
|
903
|
+
try {
|
|
904
|
+
await options.beforeInstallLink?.(options.reportedPath, temporaryPath);
|
|
905
|
+
if (!await claimedBackupStillMatchesExpected(options, backupPath)) {
|
|
906
|
+
// A writer holding the pre-rename inode changed it after pathname
|
|
907
|
+
// claim. Restore that inode when the target is still absent so future
|
|
908
|
+
// writes through the held descriptor remain visible at the target.
|
|
909
|
+
try {
|
|
910
|
+
if (await linkIfAbsent(backupPath, options.path)) {
|
|
911
|
+
preserveConcurrentBackup = false;
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
919
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
920
|
+
}
|
|
921
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, preserveConcurrentBackup ? backupPath : undefined);
|
|
922
|
+
}
|
|
923
|
+
installed = await linkIfAbsent(temporaryPath, options.path);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
if (!preserveConcurrentBackup) {
|
|
927
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath);
|
|
928
|
+
}
|
|
929
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
930
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
931
|
+
}
|
|
932
|
+
if (!installed) {
|
|
933
|
+
// A newer target already exists; the backup is only the superseded
|
|
934
|
+
// expected snapshot and is safe to remove.
|
|
935
|
+
preserveConcurrentBackup = false;
|
|
936
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath);
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
await options.afterInstallLink?.(options.reportedPath, temporaryPath);
|
|
940
|
+
await assertPromotedContents(options, temporaryPath);
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
// If a racing writer deleted our new target, restore the old expected
|
|
944
|
+
// snapshot. If it wrote a newer target, exclusive link preserves it.
|
|
945
|
+
const backupStillExpected = await claimedBackupStillMatchesExpected(options, backupPath);
|
|
946
|
+
try {
|
|
947
|
+
const restored = await linkIfAbsent(backupPath, options.path);
|
|
948
|
+
if (restored || backupStillExpected) {
|
|
949
|
+
preserveConcurrentBackup = false;
|
|
950
|
+
}
|
|
951
|
+
else {
|
|
952
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
957
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
958
|
+
}
|
|
959
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, preserveConcurrentBackup ? backupPath : undefined);
|
|
960
|
+
}
|
|
961
|
+
if (!await claimedBackupStillMatchesExpected(options, backupPath)) {
|
|
962
|
+
// The new target is already installed. Preserve the mutated claimed
|
|
963
|
+
// inode as an owner-only recovery copy instead of discarding its bytes.
|
|
964
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
965
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath, undefined, backupPath);
|
|
966
|
+
}
|
|
967
|
+
preserveConcurrentBackup = false;
|
|
968
|
+
}
|
|
969
|
+
finally {
|
|
970
|
+
if (!preserveConcurrentBackup) {
|
|
971
|
+
// Once a newer target exists, the backup is only the superseded expected
|
|
972
|
+
// snapshot. Removing it cannot discard the racing writer's bytes.
|
|
973
|
+
await rm(backupPath, { force: true });
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
await tightenFileOwnerOnlyBestEffort(backupPath);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
async function claimedBackupStillMatchesExpected(options, backupPath) {
|
|
981
|
+
try {
|
|
982
|
+
const current = await readSafeFile(backupPath, basename(options.path) === ".gitignore" ? "unsafe-gitignore-path" : "unsafe-env-path", basename(options.path) === ".gitignore" ? "malformed-gitignore" : "malformed-env", options.ownerUid);
|
|
983
|
+
return current.exists && options.expected.exists && sameMovedFileSnapshot(current, options.expected);
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
function sameMovedFileSnapshot(current, expected) {
|
|
990
|
+
return current.dev === expected.dev &&
|
|
991
|
+
current.ino === expected.ino &&
|
|
992
|
+
current.uid === expected.uid &&
|
|
993
|
+
current.nlink === expected.nlink &&
|
|
994
|
+
current.size === expected.size &&
|
|
995
|
+
current.mode === expected.mode &&
|
|
996
|
+
current.contents.equals(expected.contents);
|
|
997
|
+
}
|
|
998
|
+
async function linkIfAbsent(source, target) {
|
|
999
|
+
try {
|
|
1000
|
+
await link(source, target);
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
catch (error) {
|
|
1004
|
+
if (error.code === "EEXIST")
|
|
1005
|
+
return false;
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
async function assertPromotedContents(options, temporaryPath) {
|
|
1010
|
+
const temporary = await readSafeFile(temporaryPath, basename(options.path) === ".gitignore" ? "unsafe-gitignore-path" : "unsafe-env-path", basename(options.path) === ".gitignore" ? "malformed-gitignore" : "malformed-env", options.ownerUid, [2]);
|
|
1011
|
+
const promoted = await readSafeFile(options.path, basename(options.path) === ".gitignore" ? "unsafe-gitignore-path" : "unsafe-env-path", basename(options.path) === ".gitignore" ? "malformed-gitignore" : "malformed-env", options.ownerUid, [2], options.reportedPath);
|
|
1012
|
+
if (!temporary.exists ||
|
|
1013
|
+
!promoted.exists ||
|
|
1014
|
+
temporary.dev !== promoted.dev ||
|
|
1015
|
+
temporary.ino !== promoted.ino ||
|
|
1016
|
+
!temporary.contents.equals(options.contents) ||
|
|
1017
|
+
!promoted.contents.equals(options.contents) ||
|
|
1018
|
+
fileMode(promoted) !== options.mode) {
|
|
1019
|
+
throw new SecretEnvConcurrentModificationError(options.reportedPath);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
async function tightenFileOwnerOnlyBestEffort(path) {
|
|
1023
|
+
let handle;
|
|
1024
|
+
try {
|
|
1025
|
+
handle = await open(path, constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1026
|
+
if ((await handle.stat()).isFile())
|
|
1027
|
+
await handle.chmod(0o600);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
// The recovery copy is already fail-closed and ignored; preserve bytes.
|
|
1031
|
+
}
|
|
1032
|
+
finally {
|
|
1033
|
+
await handle?.close();
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
async function createMonoAgentConfigExclusively(path, config) {
|
|
1037
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1038
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.mono-agent-${randomUUID()}.tmp`);
|
|
1039
|
+
let handle;
|
|
1040
|
+
try {
|
|
1041
|
+
handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
1042
|
+
await handle.writeFile(`${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8" });
|
|
1043
|
+
await handle.sync();
|
|
1044
|
+
await handle.close();
|
|
1045
|
+
handle = undefined;
|
|
1046
|
+
// link is an atomic create-if-absent operation. Unlike rename it cannot
|
|
1047
|
+
// replace a config another process created while the wizard was running.
|
|
1048
|
+
await link(temporaryPath, path);
|
|
1049
|
+
await syncDirectoryBestEffort(dirname(path));
|
|
1050
|
+
}
|
|
1051
|
+
finally {
|
|
1052
|
+
await handle?.close();
|
|
1053
|
+
await rm(temporaryPath, { force: true });
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
async function removeCreatedConfigIfUnchanged(path, expectedIdentity, expectedContents) {
|
|
1057
|
+
let handle;
|
|
1058
|
+
try {
|
|
1059
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
1060
|
+
const opened = await handle.stat();
|
|
1061
|
+
if (!opened.isFile() || opened.nlink !== 1 || !sameFileIdentity(opened, expectedIdentity))
|
|
1062
|
+
return false;
|
|
1063
|
+
if (await handle.readFile({ encoding: "utf8" }) !== expectedContents)
|
|
1064
|
+
return false;
|
|
1065
|
+
const named = await lstat(path);
|
|
1066
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || !sameFileIdentity(named, expectedIdentity)) {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
await unlink(path);
|
|
1070
|
+
await syncDirectoryBestEffort(dirname(path));
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
return error.code === "ENOENT";
|
|
1075
|
+
}
|
|
1076
|
+
finally {
|
|
1077
|
+
await handle?.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function sameFileIdentity(value, expected) {
|
|
1081
|
+
return value.dev === expected.dev && value.ino === expected.ino;
|
|
1082
|
+
}
|
|
1083
|
+
async function assertSnapshotUnchanged(path, reportedPath, expected, ownerUid) {
|
|
1084
|
+
let current;
|
|
1085
|
+
try {
|
|
1086
|
+
current = await readSafeFile(path, basename(path) === ".gitignore" ? "unsafe-gitignore-path" : "unsafe-env-path", basename(path) === ".gitignore" ? "malformed-gitignore" : "malformed-env", ownerUid, [1], reportedPath);
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
if (error.code === "ENOENT") {
|
|
1090
|
+
if (!expected.exists)
|
|
1091
|
+
return;
|
|
1092
|
+
throw new SecretEnvConcurrentModificationError(reportedPath);
|
|
1093
|
+
}
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
if (current.exists !== expected.exists)
|
|
1097
|
+
throw new SecretEnvConcurrentModificationError(reportedPath);
|
|
1098
|
+
if (!current.exists || !expected.exists)
|
|
1099
|
+
return;
|
|
1100
|
+
if (current.dev !== expected.dev ||
|
|
1101
|
+
current.ino !== expected.ino ||
|
|
1102
|
+
current.uid !== expected.uid ||
|
|
1103
|
+
current.nlink !== expected.nlink ||
|
|
1104
|
+
current.size !== expected.size ||
|
|
1105
|
+
current.mtimeMs !== expected.mtimeMs ||
|
|
1106
|
+
current.ctimeMs !== expected.ctimeMs ||
|
|
1107
|
+
current.mode !== expected.mode ||
|
|
1108
|
+
!current.contents.equals(expected.contents)) {
|
|
1109
|
+
throw new SecretEnvConcurrentModificationError(reportedPath);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
async function syncDirectoryBestEffort(path) {
|
|
1113
|
+
let handle;
|
|
1114
|
+
try {
|
|
1115
|
+
handle = await open(path, constants.O_RDONLY);
|
|
1116
|
+
await handle.sync();
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
// The file itself is already fsynced; some platforms/filesystems reject directory fsync.
|
|
1120
|
+
}
|
|
1121
|
+
finally {
|
|
1122
|
+
await handle?.close();
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
async function isGitTracked(path) {
|
|
1126
|
+
const directory = dirname(path);
|
|
1127
|
+
const repository = await runGit(["-C", directory, "rev-parse", "--show-toplevel"]);
|
|
1128
|
+
if (!repository.ok) {
|
|
1129
|
+
if (await hasGitMetadata(directory)) {
|
|
1130
|
+
throw new SecretEnvPersistenceRefusedError("git-safety-unavailable", `Automatic secret persistence could not verify git tracking for ${path}.`);
|
|
1131
|
+
}
|
|
1132
|
+
return false;
|
|
1133
|
+
}
|
|
1134
|
+
const root = repository.stdout.replace(/\r?\n$/u, "");
|
|
1135
|
+
const canonicalPath = join(await realpath(directory), basename(path));
|
|
1136
|
+
const tracked = await runGit(["-C", root, "ls-files", "--error-unmatch", "--", relative(root, canonicalPath)]);
|
|
1137
|
+
if (tracked.ok)
|
|
1138
|
+
return true;
|
|
1139
|
+
if (Number(tracked.code) === 1)
|
|
1140
|
+
return false;
|
|
1141
|
+
throw new SecretEnvPersistenceRefusedError("git-safety-unavailable", `Automatic secret persistence could not verify git tracking for ${path}.`);
|
|
1142
|
+
}
|
|
1143
|
+
/** Recheck the complete durable dotenv guard without changing either file. */
|
|
1144
|
+
export async function verifySecretEnvPersistenceGuard(path) {
|
|
1145
|
+
const target = await resolveSafeSecretEnvTarget(path);
|
|
1146
|
+
const envSnapshot = await readSafeFile(target.path, "unsafe-env-path", "malformed-env", target.ownerUid);
|
|
1147
|
+
if (!envSnapshot.exists || fileMode(envSnapshot) !== 0o600 || await isGitTracked(target.path)) {
|
|
1148
|
+
return false;
|
|
1149
|
+
}
|
|
1150
|
+
const gitIgnorePath = join(dirname(target.path), ".gitignore");
|
|
1151
|
+
const gitIgnoreSnapshot = await readSafeFile(gitIgnorePath, "unsafe-gitignore-path", "malformed-gitignore", target.ownerUid);
|
|
1152
|
+
return gitIgnoreSnapshot.exists && ensureExactEnvIgnore(snapshotText(gitIgnoreSnapshot)) === snapshotText(gitIgnoreSnapshot);
|
|
1153
|
+
}
|
|
1154
|
+
function runGit(args) {
|
|
1155
|
+
return new Promise((resolveResult) => {
|
|
1156
|
+
execFile("git", [...args], { encoding: "utf8" }, (error, stdout) => {
|
|
1157
|
+
if (error === null) {
|
|
1158
|
+
resolveResult({ ok: true, stdout });
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
const code = error.code;
|
|
1162
|
+
resolveResult({ ok: false, stdout, ...(code === undefined ? {} : { code }) });
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
async function hasGitMetadata(start) {
|
|
1167
|
+
let current = resolve(start);
|
|
1168
|
+
while (true) {
|
|
1169
|
+
try {
|
|
1170
|
+
await lstat(join(current, ".git"));
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
if (error.code !== "ENOENT")
|
|
1175
|
+
return true;
|
|
1176
|
+
}
|
|
1177
|
+
const parent = dirname(current);
|
|
1178
|
+
if (parent === current)
|
|
1179
|
+
return false;
|
|
1180
|
+
current = parent;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function escapeRegExp(value) {
|
|
1184
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
62
1185
|
}
|
|
63
|
-
function identityTemplate(dir, knowledgeFiles) {
|
|
1186
|
+
function identityTemplate(dir, agentName, purpose, knowledgeFiles) {
|
|
64
1187
|
const knowledgeSection = knowledgeFiles.length === 0
|
|
65
|
-
? "
|
|
1188
|
+
? "No existing project knowledge files were detected. Add references to authoritative knowledge here when available."
|
|
66
1189
|
: [
|
|
67
1190
|
"This folder already carries knowledge the agent must read and respect:",
|
|
68
1191
|
"",
|
|
@@ -70,11 +1193,11 @@ function identityTemplate(dir, knowledgeFiles) {
|
|
|
70
1193
|
].join("\n");
|
|
71
1194
|
return `# Identity
|
|
72
1195
|
|
|
73
|
-
You are
|
|
1196
|
+
You are ${agentName}, a mono agent constructed in \`${basename(dir)}\`.
|
|
74
1197
|
|
|
75
1198
|
## Role
|
|
76
1199
|
|
|
77
|
-
|
|
1200
|
+
${purpose}
|
|
78
1201
|
|
|
79
1202
|
## Knowledge
|
|
80
1203
|
|
|
@@ -96,4 +1219,75 @@ async function pathExists(path) {
|
|
|
96
1219
|
return false;
|
|
97
1220
|
}
|
|
98
1221
|
}
|
|
1222
|
+
function scaffoldPathEscapesRoot(pathRelative) {
|
|
1223
|
+
return pathRelative === ".." || pathRelative.startsWith(`..${sep}`) || isAbsolute(pathRelative);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* Refuse generated scaffold artifacts whose parent chain escapes through a
|
|
1227
|
+
* symlink. This protects ordinary and save-incomplete init alike; staging is a
|
|
1228
|
+
* validation boundary, not authorization to weaken the eventual write path.
|
|
1229
|
+
*/
|
|
1230
|
+
async function ensureSafeScaffoldParent(root, filePath, createMissing) {
|
|
1231
|
+
const pathRelative = relative(root, filePath);
|
|
1232
|
+
if (pathRelative.length === 0 || scaffoldPathEscapesRoot(pathRelative)) {
|
|
1233
|
+
throw new Error(`Refusing to create scaffold artifact outside the agent directory: ${filePath}`);
|
|
1234
|
+
}
|
|
1235
|
+
const parentRelative = dirname(pathRelative);
|
|
1236
|
+
if (parentRelative === ".")
|
|
1237
|
+
return;
|
|
1238
|
+
let current = root;
|
|
1239
|
+
for (const segment of parentRelative.split(sep).filter((part) => part.length > 0)) {
|
|
1240
|
+
const next = join(current, segment);
|
|
1241
|
+
let entry;
|
|
1242
|
+
try {
|
|
1243
|
+
entry = await lstat(next);
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
if (error.code !== "ENOENT")
|
|
1247
|
+
throw error;
|
|
1248
|
+
if (!createMissing)
|
|
1249
|
+
return;
|
|
1250
|
+
try {
|
|
1251
|
+
await mkdir(next);
|
|
1252
|
+
}
|
|
1253
|
+
catch (mkdirError) {
|
|
1254
|
+
if (mkdirError.code !== "EEXIST")
|
|
1255
|
+
throw mkdirError;
|
|
1256
|
+
}
|
|
1257
|
+
entry = await lstat(next);
|
|
1258
|
+
}
|
|
1259
|
+
if (entry.isSymbolicLink()) {
|
|
1260
|
+
throw new Error(`Refusing to create scaffold artifact through symbolic-link parent: ${next}`);
|
|
1261
|
+
}
|
|
1262
|
+
if (!entry.isDirectory()) {
|
|
1263
|
+
throw new Error(`Refusing to create scaffold artifact through non-directory parent: ${next}`);
|
|
1264
|
+
}
|
|
1265
|
+
current = next;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
async function assertSafeScaffoldTarget(path, label) {
|
|
1269
|
+
try {
|
|
1270
|
+
const entry = await lstat(path);
|
|
1271
|
+
if (entry.isSymbolicLink()) {
|
|
1272
|
+
throw new Error(`Refusing to use symbolic-link scaffold ${label}: ${path}`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
if (error.code !== "ENOENT")
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
async function createSafeScaffoldDirectory(path) {
|
|
1281
|
+
try {
|
|
1282
|
+
await mkdir(path);
|
|
1283
|
+
}
|
|
1284
|
+
catch (error) {
|
|
1285
|
+
if (error.code !== "EEXIST")
|
|
1286
|
+
throw error;
|
|
1287
|
+
}
|
|
1288
|
+
const entry = await lstat(path);
|
|
1289
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
1290
|
+
throw new Error(`Refusing non-directory scaffold working path: ${path}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
99
1293
|
//# sourceMappingURL=init.js.map
|