-
Notifications
You must be signed in to change notification settings - Fork 118
Expand file tree
/
Copy pathtrust.ts
More file actions
230 lines (211 loc) · 8.56 KB
/
Copy pathtrust.ts
File metadata and controls
230 lines (211 loc) · 8.56 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
#!/usr/bin/env bun
/**
* `mux trust` - Headless project trust management.
*
* Trust gates all repo-controlled automation (project workflows, hooks, and
* .mux configuration), so it lives at the top level of the CLI rather than
* under any single feature's subcommand. The desktop app records trust in
* ~/.mux/config.json via Settings → Security; this command writes the same
* entry directly so headless environments (no desktop app or server running,
* project possibly never added to mux) can grant or revoke trust. A running
* desktop instance picks the change up through its config-file watcher.
*/
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { promisify } from "node:util";
import { Command } from "commander";
import { getErrorMessage } from "@/common/utils/errors";
import { Config } from "@/node/config";
import { isProjectTrusted } from "@/node/utils/projectTrust";
import { getParseOptions } from "./argv";
import { exitAfterStdoutFlush } from "./processExit";
const execFileAsync = promisify(execFile);
export interface ResolveProjectDirInput {
cwd: string;
explicitDir?: string;
}
interface TrustCLIOptions {
dir?: string;
revoke?: boolean;
json?: boolean;
}
/**
* Resolve the project directory for trust and workflow discovery: an explicit
* --dir wins; otherwise the git toplevel of cwd (falling back to cwd itself).
*/
export async function resolveProjectDir(input: ResolveProjectDirInput): Promise<string> {
if (input.explicitDir != null) {
const explicitDir = path.resolve(input.cwd, input.explicitDir);
await ensureDirectory(explicitDir);
return explicitDir;
}
const cwd = path.resolve(input.cwd);
await ensureDirectory(cwd);
const gitRoot = await findGitRoot(cwd);
return gitRoot ?? cwd;
}
async function ensureDirectory(dirPath: string): Promise<void> {
const stats = await fs.stat(dirPath);
if (!stats.isDirectory()) {
throw new Error(`"${dirPath}" is not a directory`);
}
}
export async function findGitRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd });
const gitRoot = stdout.trim();
return gitRoot.length > 0 ? gitRoot : null;
} catch {
return null;
}
}
/**
* For a linked git worktree, returns the main repository working directory (the
* parent of the common `.git` dir). Returns null for the main checkout itself,
* bare repos, and non-git directories.
*/
export async function findMainRepoDir(projectDir: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], {
cwd: projectDir,
});
const commonDir = stdout.trim();
if (commonDir.length === 0) {
return null;
}
// The main checkout reports a relative ".git"; linked worktrees report the main
// repo's .git dir. Resolve against projectDir so both shapes become absolute.
const absoluteCommonDir = path.resolve(projectDir, commonDir);
if (path.basename(absoluteCommonDir) !== ".git") {
return null;
}
const mainRepoDir = path.dirname(absoluteCommonDir);
return mainRepoDir === projectDir ? null : mainRepoDir;
} catch {
return null;
}
}
/**
* Resolve project trust for repo-controlled automation. Trust is keyed in
* config.json by the registered project path, so linked git worktrees (e.g. mux
* workspaces under ~/.mux/src/<project>/<branch>) miss a direct lookup of their own
* checkout path. Mirror the desktop app, which scans the workspace checkout but
* resolves trust against the registered project path (resolveWorkflowContext in
* src/node/orpc/router.ts), by also accepting trust granted to the main repository.
*/
export async function resolveProjectTrusted(
realConfig: Config,
projectDir: string
): Promise<boolean> {
if (isProjectTrusted(realConfig, projectDir)) {
return true;
}
const mainRepoDir = await findMainRepoDir(projectDir);
return mainRepoDir != null && isProjectTrusted(realConfig, mainRepoDir);
}
async function runTrust(options: TrustCLIOptions): Promise<number> {
const projectDir = await resolveProjectDir({
cwd: process.cwd(),
explicitDir: options.dir,
});
const realConfig = new Config();
const trusted = options.revoke !== true;
// Linked worktrees are ephemeral aliases of the main repository: key trust by the
// main repo path (matching resolveProjectTrusted) instead of accumulating
// per-worktree entries in config.json. findMainRepoDir(gitRoot) is null for a main
// checkout, so an explicit non-worktree --dir is trusted exactly as given and
// sub-project trust stays possible.
const gitRoot = await findGitRoot(projectDir);
const mainRepoDir = gitRoot == null ? null : await findMainRepoDir(gitRoot);
const trustDir = mainRepoDir ?? projectDir;
// Compare and verify against the checkout's *effective* trust (direct entry or
// main-repo fallback), not just the canonical entry: that is what workflow
// discovery and hooks actually consult.
const wasTrusted = await resolveProjectTrusted(realConfig, projectDir);
if (wasTrusted !== trusted) {
await realConfig.editConfig((config) => {
const setEntryTrusted = (dir: string, createIfMissing: boolean) => {
let project = config.projects.get(dir);
if (!project) {
if (!createIfMissing) {
return;
}
// Mirror the desktop setTrust handler (projects.setTrust in
// src/node/orpc/router.ts): create a minimal project entry when the project
// was never added to mux, so headless CLI use works without the desktop app
// or server.
project = { workspaces: [] };
config.projects.set(dir, project);
}
project.trusted = trusted;
};
setEntryTrusted(trustDir, true);
if (!trusted) {
// Revocation must also clear direct entries for the checkout paths themselves
// (e.g. a worktree added as its own project, or an older/manual config entry);
// resolveProjectTrusted's direct lookup would otherwise keep this checkout
// trusted after we report revocation. Skip entry creation: absence already
// means untrusted.
for (const dir of new Set([projectDir, gitRoot ?? projectDir])) {
if (dir !== trustDir) {
setEntryTrusted(dir, false);
}
}
}
return config;
});
// Config.saveConfig swallows write failures (self-healing keeps the desktop app
// alive on bad disks), so editConfig resolves even when nothing was persisted.
// A headless trust command must not report success in that case: re-read from
// disk and fail loudly instead of letting automation proceed with a wrong
// assumption about the project's trust state.
if ((await resolveProjectTrusted(realConfig, projectDir)) !== trusted) {
throw new Error(
`Failed to persist trust change for ${trustDir}. Check that ${realConfig.rootDir} is a writable directory.`
);
}
}
if (options.json === true) {
process.stdout.write(`${JSON.stringify({ projectPath: trustDir, trusted })}\n`);
return 0;
}
if (mainRepoDir != null) {
process.stdout.write(`Resolved linked git worktree ${projectDir} to its main repository.\n`);
}
if (wasTrusted === trusted) {
process.stdout.write(`Project already ${trusted ? "trusted" : "untrusted"}: ${trustDir}\n`);
} else if (trusted) {
process.stdout.write(`Trusted project: ${trustDir}\n`);
process.stdout.write(
"Trusted projects can run repo-controlled code: project workflows, hooks, and .mux configuration.\n"
);
} else {
process.stdout.write(`Revoked project trust: ${trustDir}\n`);
}
return 0;
}
export async function main(): Promise<number> {
const program = new Command();
program
.name("mux trust")
.description(
"Trust the project in the current directory (or --dir) so repo-controlled automation (project workflows, hooks, .mux configuration) can run"
)
.option("-d, --dir <path>", "project directory")
.option("--revoke", "revoke trust instead of granting it")
.option("--json", "emit JSON output")
.action(async () => {
process.exitCode = await runTrust(program.opts<TrustCLIOptions>());
});
await program.parseAsync(process.argv, getParseOptions());
return typeof process.exitCode === "number" ? process.exitCode : 0;
}
if (require.main === module) {
main()
.then(exitAfterStdoutFlush)
.catch((error: unknown) => {
console.error(`Error: ${getErrorMessage(error)}`);
process.exit(1);
});
}