impel-cli 0.18.18-beta.0 → 0.19.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 +62 -0
- package/package.json +1 -1
- package/src/agents.js +7 -37
- package/src/apps.js +1 -6
- package/src/cli.js +5 -0
- package/src/commands/launch.js +2 -11
- package/src/commands/pat.js +2 -2
- package/src/commands/remote.js +559 -0
- package/src/remote/aws.js +142 -0
- package/src/remote/process.js +131 -0
- package/src/remote/state.js +202 -0
- package/src/remote/transfer.js +293 -0
- package/src/verbatimRelay.js +0 -64
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
import { redactSecretText } from "../config.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export class RemoteProcessError extends Error {
|
|
10
|
+
constructor(command, args, result) {
|
|
11
|
+
const rendered = [command, ...args].map((value) => JSON.stringify(String(value))).join(" ");
|
|
12
|
+
const detail = redactSecretText(result?.stderr || result?.error?.message || "command failed").trim();
|
|
13
|
+
super(`${rendered} failed${detail ? `: ${detail}` : ""}`);
|
|
14
|
+
this.command = command;
|
|
15
|
+
this.args = [...args];
|
|
16
|
+
this.status = Number.isInteger(result?.status) ? result.status : 1;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function findExecutable(command, environment = process.env) {
|
|
21
|
+
if (path.isAbsolute(command)) {
|
|
22
|
+
try {
|
|
23
|
+
fs.accessSync(command, fs.constants.X_OK);
|
|
24
|
+
return command;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const pathValue = environment.PATH || "";
|
|
30
|
+
for (const directory of pathValue.split(path.delimiter)) {
|
|
31
|
+
if (!directory) continue;
|
|
32
|
+
const candidate = path.join(directory, command);
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
35
|
+
return candidate;
|
|
36
|
+
} catch {
|
|
37
|
+
// Keep searching PATH.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function requireExecutables(commands, environment = process.env) {
|
|
44
|
+
const missing = commands.filter((command) => !findExecutable(command, environment));
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
throw new Error(`required command${missing.length === 1 ? " is" : "s are"} not installed: ${missing.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function runCapture(command, args = [], {
|
|
51
|
+
cwd,
|
|
52
|
+
env = process.env,
|
|
53
|
+
input,
|
|
54
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
55
|
+
allowFailure = false,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const result = spawnSync(command, args, {
|
|
58
|
+
cwd,
|
|
59
|
+
env,
|
|
60
|
+
input,
|
|
61
|
+
encoding: "utf8",
|
|
62
|
+
maxBuffer,
|
|
63
|
+
windowsHide: true,
|
|
64
|
+
});
|
|
65
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
66
|
+
throw new RemoteProcessError(command, args, result);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
70
|
+
stdout: result.stdout || "",
|
|
71
|
+
stderr: result.stderr || "",
|
|
72
|
+
error: result.error || null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function runBuffer(command, args = [], {
|
|
77
|
+
cwd,
|
|
78
|
+
env = process.env,
|
|
79
|
+
input,
|
|
80
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
81
|
+
allowFailure = false,
|
|
82
|
+
} = {}) {
|
|
83
|
+
const result = spawnSync(command, args, {
|
|
84
|
+
cwd,
|
|
85
|
+
env,
|
|
86
|
+
input,
|
|
87
|
+
encoding: null,
|
|
88
|
+
maxBuffer,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
});
|
|
91
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
92
|
+
throw new RemoteProcessError(command, args, {
|
|
93
|
+
...result,
|
|
94
|
+
stderr: result.stderr?.toString("utf8") || "",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
status: Number.isInteger(result.status) ? result.status : 1,
|
|
99
|
+
stdout: result.stdout || Buffer.alloc(0),
|
|
100
|
+
stderr: result.stderr || Buffer.alloc(0),
|
|
101
|
+
error: result.error || null,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function runInteractive(command, args = [], {
|
|
106
|
+
cwd,
|
|
107
|
+
env = process.env,
|
|
108
|
+
inputFile,
|
|
109
|
+
allowFailure = false,
|
|
110
|
+
} = {}) {
|
|
111
|
+
let descriptor;
|
|
112
|
+
try {
|
|
113
|
+
if (inputFile) descriptor = fs.openSync(inputFile, "r");
|
|
114
|
+
const result = spawnSync(command, args, {
|
|
115
|
+
cwd,
|
|
116
|
+
env,
|
|
117
|
+
stdio: [descriptor ?? "inherit", "inherit", "inherit"],
|
|
118
|
+
windowsHide: false,
|
|
119
|
+
});
|
|
120
|
+
if (!allowFailure && (result.error || result.status !== 0)) {
|
|
121
|
+
throw new RemoteProcessError(command, args, result);
|
|
122
|
+
}
|
|
123
|
+
return Number.isInteger(result.status) ? result.status : 1;
|
|
124
|
+
} finally {
|
|
125
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function sleep(milliseconds) {
|
|
130
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
131
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { CONFIG_DIR } from "../config.js";
|
|
7
|
+
import { IMPEL_CLI_ENTRYPOINT } from "../selfInvocation.js";
|
|
8
|
+
import { renameWithWindowsRetry } from "../windowsFs.js";
|
|
9
|
+
|
|
10
|
+
export const REMOTE_STATE_VERSION = 1;
|
|
11
|
+
export const REMOTE_ROOT = process.env.IMPEL_REMOTE_STATE_DIR || path.join(CONFIG_DIR, "remote");
|
|
12
|
+
export const REMOTE_RUNS_DIR = path.join(REMOTE_ROOT, "runs");
|
|
13
|
+
export const SSH_CONFIG_PATH = process.env.IMPEL_REMOTE_SSH_CONFIG || path.join(os.homedir(), ".ssh", "config");
|
|
14
|
+
|
|
15
|
+
const RUN_ID_RE = /^run_[a-z0-9]{16}$/u;
|
|
16
|
+
const SSH_START_PREFIX = "# >>> impel remote ";
|
|
17
|
+
const SSH_END_PREFIX = "# <<< impel remote ";
|
|
18
|
+
|
|
19
|
+
function lstatOrNull(target) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.lstatSync(target);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error?.code === "ENOENT") return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ensurePrivateDirectory(directory) {
|
|
29
|
+
const existing = lstatOrNull(directory);
|
|
30
|
+
if (existing?.isSymbolicLink()) throw new Error(`${directory} must not be a symbolic link`);
|
|
31
|
+
if (existing && !existing.isDirectory()) throw new Error(`${directory} must be a directory`);
|
|
32
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
33
|
+
try { fs.chmodSync(directory, 0o700); } catch { /* Best effort on Windows. */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function atomicWrite(filePath, contents, mode = 0o600) {
|
|
37
|
+
ensurePrivateDirectory(path.dirname(filePath));
|
|
38
|
+
const existing = lstatOrNull(filePath);
|
|
39
|
+
if (existing?.isSymbolicLink()) throw new Error(`${filePath} must not be a symbolic link`);
|
|
40
|
+
if (existing && !existing.isFile()) throw new Error(`${filePath} must be a regular file`);
|
|
41
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(temporaryPath, contents, { mode });
|
|
44
|
+
renameWithWindowsRetry(temporaryPath, filePath);
|
|
45
|
+
try { fs.chmodSync(filePath, mode); } catch { /* Best effort on Windows. */ }
|
|
46
|
+
} finally {
|
|
47
|
+
try { fs.rmSync(temporaryPath, { force: true }); } catch { /* Rename removed it. */ }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function newRunId() {
|
|
52
|
+
return `run_${crypto.randomBytes(10).toString("hex").slice(0, 16)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateRunId(runId) {
|
|
56
|
+
const normalized = String(runId || "").trim();
|
|
57
|
+
if (!RUN_ID_RE.test(normalized)) throw new Error(`invalid remote run id ${JSON.stringify(normalized)}`);
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function runDirectory(runId) {
|
|
62
|
+
return path.join(REMOTE_RUNS_DIR, validateRunId(runId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runPaths(runId) {
|
|
66
|
+
const root = runDirectory(runId);
|
|
67
|
+
return {
|
|
68
|
+
root,
|
|
69
|
+
state: path.join(root, "run.json"),
|
|
70
|
+
privateKey: path.join(root, "id_ed25519"),
|
|
71
|
+
publicKey: path.join(root, "id_ed25519.pub"),
|
|
72
|
+
knownHosts: path.join(root, "known_hosts"),
|
|
73
|
+
bundle: path.join(root, "repo.bundle"),
|
|
74
|
+
fileList: path.join(root, "files.list"),
|
|
75
|
+
archive: path.join(root, "working-tree.tar"),
|
|
76
|
+
deletedList: path.join(root, "deleted.list"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function initializeRun(runId) {
|
|
81
|
+
const paths = runPaths(runId);
|
|
82
|
+
ensurePrivateDirectory(paths.root);
|
|
83
|
+
return paths;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function writeRunState(state) {
|
|
87
|
+
const runId = validateRunId(state?.runId);
|
|
88
|
+
const next = { ...state, schemaVersion: REMOTE_STATE_VERSION, updatedAt: new Date().toISOString() };
|
|
89
|
+
atomicWrite(runPaths(runId).state, `${JSON.stringify(next, null, 2)}\n`);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readRunState(runId) {
|
|
94
|
+
const statePath = runPaths(runId).state;
|
|
95
|
+
let value;
|
|
96
|
+
try {
|
|
97
|
+
value = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error?.code === "ENOENT") throw new Error(`remote run ${runId} does not exist`);
|
|
100
|
+
throw new Error(`${statePath} is not a valid remote run state file`);
|
|
101
|
+
}
|
|
102
|
+
if (value?.schemaVersion !== REMOTE_STATE_VERSION || value?.runId !== runId) {
|
|
103
|
+
throw new Error(`${statePath} has an unsupported remote run state version`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function listRunStates() {
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = fs.readdirSync(REMOTE_RUNS_DIR, { withFileTypes: true });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code === "ENOENT") return [];
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const states = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (!entry.isDirectory() || !RUN_ID_RE.test(entry.name)) continue;
|
|
119
|
+
try { states.push(readRunState(entry.name)); } catch { /* A corrupt run is not selected implicitly. */ }
|
|
120
|
+
}
|
|
121
|
+
return states.sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt)));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveRunId(requested, { includeStopped = false } = {}) {
|
|
125
|
+
if (requested) return validateRunId(requested);
|
|
126
|
+
const current = listRunStates().find((state) => includeStopped || state.status !== "stopped");
|
|
127
|
+
if (!current) throw new Error("no active remote run found; start one with `impel remote up`");
|
|
128
|
+
return current.runId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function sshAlias(runId) {
|
|
132
|
+
return `impel-${validateRunId(runId).replaceAll("_", "-")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sshQuote(value) {
|
|
136
|
+
const text = String(value);
|
|
137
|
+
if (/[\r\n\0]/u.test(text)) throw new Error("unsafe value in managed SSH configuration");
|
|
138
|
+
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function removeManagedBlock(text, runId) {
|
|
142
|
+
const startMarker = `${SSH_START_PREFIX}${runId} >>>`;
|
|
143
|
+
const endMarker = `${SSH_END_PREFIX}${runId} <<<`;
|
|
144
|
+
const start = text.indexOf(startMarker);
|
|
145
|
+
if (start === -1) return text;
|
|
146
|
+
const end = text.indexOf(endMarker, start + startMarker.length);
|
|
147
|
+
if (end === -1) throw new Error(`${SSH_CONFIG_PATH} has an incomplete Impel remote block for ${runId}`);
|
|
148
|
+
const after = end + endMarker.length;
|
|
149
|
+
return `${text.slice(0, start).trimEnd()}${text.slice(after).trimStart() ? `\n${text.slice(after).trimStart()}` : ""}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function installSshAlias(state) {
|
|
153
|
+
const { runId } = state;
|
|
154
|
+
const paths = runPaths(runId);
|
|
155
|
+
const alias = state.alias || sshAlias(runId);
|
|
156
|
+
const current = fs.existsSync(SSH_CONFIG_PATH) ? fs.readFileSync(SSH_CONFIG_PATH, "utf8") : "";
|
|
157
|
+
const preserved = removeManagedBlock(current, runId).trimEnd();
|
|
158
|
+
const proxyTokens = [process.execPath, IMPEL_CLI_ENTRYPOINT, "remote", "proxy", runId, "--port", "%p"];
|
|
159
|
+
const block = [
|
|
160
|
+
`${SSH_START_PREFIX}${runId} >>>`,
|
|
161
|
+
`Host ${alias}`,
|
|
162
|
+
` HostName ${runId}.remote.impel.invalid`,
|
|
163
|
+
" User agent",
|
|
164
|
+
" Port 22",
|
|
165
|
+
` IdentityFile ${sshQuote(paths.privateKey)}`,
|
|
166
|
+
" IdentitiesOnly yes",
|
|
167
|
+
" BatchMode yes",
|
|
168
|
+
" StrictHostKeyChecking accept-new",
|
|
169
|
+
` UserKnownHostsFile ${sshQuote(paths.knownHosts)}`,
|
|
170
|
+
` ProxyCommand ${proxyTokens.map(sshQuote).join(" ")}`,
|
|
171
|
+
`${SSH_END_PREFIX}${runId} <<<`,
|
|
172
|
+
].join("\n");
|
|
173
|
+
// OpenSSH keeps the first value it obtains for most settings. Put concrete
|
|
174
|
+
// Impel hosts before user wildcard blocks so a preceding `Host *` cannot
|
|
175
|
+
// replace this run's user, hostname, key, or ProxyCommand.
|
|
176
|
+
atomicWrite(SSH_CONFIG_PATH, `${block}${preserved ? `\n\n${preserved}` : ""}\n`);
|
|
177
|
+
return alias;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function removeSshAlias(runId) {
|
|
181
|
+
if (!fs.existsSync(SSH_CONFIG_PATH)) return false;
|
|
182
|
+
const current = fs.readFileSync(SSH_CONFIG_PATH, "utf8");
|
|
183
|
+
const next = removeManagedBlock(current, validateRunId(runId));
|
|
184
|
+
if (next === current) return false;
|
|
185
|
+
atomicWrite(SSH_CONFIG_PATH, next.trim() ? `${next.trimEnd()}\n` : "");
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function removeRunSecrets(runId) {
|
|
190
|
+
const paths = runPaths(runId);
|
|
191
|
+
for (const target of [
|
|
192
|
+
paths.privateKey,
|
|
193
|
+
paths.publicKey,
|
|
194
|
+
paths.knownHosts,
|
|
195
|
+
paths.bundle,
|
|
196
|
+
paths.fileList,
|
|
197
|
+
paths.archive,
|
|
198
|
+
paths.deletedList,
|
|
199
|
+
]) {
|
|
200
|
+
try { fs.rmSync(target, { force: true }); } catch { /* Best-effort local cleanup. */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { tenantCliProfilePaths } from "../cliProfiles.js";
|
|
5
|
+
import { redactSecretText } from "../config.js";
|
|
6
|
+
import { runBuffer, runCapture, runInteractive, sleep } from "./process.js";
|
|
7
|
+
import { runPaths } from "./state.js";
|
|
8
|
+
|
|
9
|
+
const REMOTE_REPO = "/workspace/repo";
|
|
10
|
+
const REMOTE_BUNDLE = "/tmp/impel-remote-repo.bundle";
|
|
11
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
|
|
12
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
13
|
+
|
|
14
|
+
function git(args, options = {}) {
|
|
15
|
+
return runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", args, options).stdout.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sshArgs(state, command) {
|
|
19
|
+
return [state.alias, command];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sshCapture(state, command, options = {}) {
|
|
23
|
+
return runCapture(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, command), options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function remoteShellQuote(value) {
|
|
27
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function remoteNode(state, source, payload) {
|
|
31
|
+
const evaluator = "eval(Buffer.from(process.argv[1],'base64').toString('utf8'))";
|
|
32
|
+
const encodedSource = Buffer.from(source, "utf8").toString("base64");
|
|
33
|
+
return sshCapture(state, `node -e ${remoteShellQuote(evaluator)} ${remoteShellQuote(encodedSource)}`, {
|
|
34
|
+
input: `${JSON.stringify(payload)}\n`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writePrivate(filePath, contents) {
|
|
39
|
+
fs.writeFileSync(filePath, contents, { mode: 0o600 });
|
|
40
|
+
try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function inspectRepository(requestedPath = process.cwd()) {
|
|
44
|
+
const requested = path.resolve(requestedPath);
|
|
45
|
+
const candidate = fs.realpathSync(requested);
|
|
46
|
+
const root = fs.realpathSync(git(["-C", candidate, "rev-parse", "--show-toplevel"]));
|
|
47
|
+
const head = git(["-C", root, "rev-parse", "HEAD"]);
|
|
48
|
+
const branchResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
49
|
+
"-C", root, "symbolic-ref", "--quiet", "--short", "HEAD",
|
|
50
|
+
], { allowFailure: true });
|
|
51
|
+
const originResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
52
|
+
"-C", root, "remote", "get-url", "origin",
|
|
53
|
+
], { allowFailure: true });
|
|
54
|
+
const userNameResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
55
|
+
"-C", root, "config", "--get", "user.name",
|
|
56
|
+
], { allowFailure: true });
|
|
57
|
+
const userEmailResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
58
|
+
"-C", root, "config", "--get", "user.email",
|
|
59
|
+
], { allowFailure: true });
|
|
60
|
+
const relativeProjectPath = path.relative(root, candidate) || ".";
|
|
61
|
+
if (relativeProjectPath === ".." || relativeProjectPath.startsWith(`..${path.sep}`)) {
|
|
62
|
+
throw new Error(`${candidate} is not inside its Git repository`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
head,
|
|
67
|
+
branch: branchResult.status === 0 ? branchResult.stdout.trim() : null,
|
|
68
|
+
origin: originResult.status === 0 ? originResult.stdout.trim() : null,
|
|
69
|
+
userName: userNameResult.status === 0 ? userNameResult.stdout.trim() : null,
|
|
70
|
+
userEmail: userEmailResult.status === 0 ? userEmailResult.stdout.trim() : null,
|
|
71
|
+
relativeProjectPath: relativeProjectPath.split(path.sep).join("/"),
|
|
72
|
+
remoteProjectPath: relativeProjectPath === "."
|
|
73
|
+
? REMOTE_REPO
|
|
74
|
+
: `${REMOTE_REPO}/${relativeProjectPath.split(path.sep).join("/")}`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createRepositoryTransfer(state, repository) {
|
|
79
|
+
const paths = runPaths(state.runId);
|
|
80
|
+
runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
81
|
+
"-C", repository.root, "bundle", "create", paths.bundle, "--all",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const deleted = runBuffer(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
85
|
+
"-C", repository.root, "ls-files", "--deleted", "-z",
|
|
86
|
+
]).stdout;
|
|
87
|
+
writePrivate(paths.deletedList, deleted);
|
|
88
|
+
const deletedPaths = new Set(splitNullBuffer(deleted).map((entry) => entry.toString("base64")));
|
|
89
|
+
const listedFiles = runBuffer(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
|
|
90
|
+
"-C", repository.root, "ls-files", "--cached", "--others", "--exclude-standard", "-z",
|
|
91
|
+
]).stdout;
|
|
92
|
+
const files = joinNullBuffer(splitNullBuffer(listedFiles).filter(
|
|
93
|
+
(entry) => !deletedPaths.has(entry.toString("base64")),
|
|
94
|
+
));
|
|
95
|
+
writePrivate(paths.fileList, files);
|
|
96
|
+
runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
|
|
97
|
+
"-cf", paths.archive,
|
|
98
|
+
"--no-xattrs",
|
|
99
|
+
"--null",
|
|
100
|
+
"-T", paths.fileList,
|
|
101
|
+
], { cwd: repository.root });
|
|
102
|
+
return paths;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function splitNullBuffer(buffer) {
|
|
106
|
+
const entries = [];
|
|
107
|
+
let start = 0;
|
|
108
|
+
while (start < buffer.length) {
|
|
109
|
+
const end = buffer.indexOf(0, start);
|
|
110
|
+
if (end === -1) throw new Error("Git returned a malformed NUL-delimited path list");
|
|
111
|
+
if (end > start) entries.push(buffer.subarray(start, end));
|
|
112
|
+
start = end + 1;
|
|
113
|
+
}
|
|
114
|
+
return entries;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function joinNullBuffer(entries) {
|
|
118
|
+
if (entries.length === 0) return Buffer.alloc(0);
|
|
119
|
+
return Buffer.concat(entries.flatMap((entry) => [entry, Buffer.from([0])]));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function waitForSsh(state, timeoutSeconds = 120) {
|
|
123
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
124
|
+
let lastError = "SSH did not accept a connection";
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
const result = sshCapture(state, "true", { allowFailure: true });
|
|
127
|
+
if (result.status === 0) return;
|
|
128
|
+
lastError = redactSecretText(result.stderr || lastError).trim();
|
|
129
|
+
await sleep(Number(process.env.IMPEL_REMOTE_SSH_POLL_MS || 2000));
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`remote runner did not become SSH-ready within ${timeoutSeconds} seconds: ${lastError}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function uploadRemoteConfiguration(state, {
|
|
135
|
+
config,
|
|
136
|
+
credential,
|
|
137
|
+
tenant,
|
|
138
|
+
environmentNames = [],
|
|
139
|
+
}) {
|
|
140
|
+
const remoteConfig = {
|
|
141
|
+
pat: credential.token,
|
|
142
|
+
gatewayUrl: config.gatewayUrl,
|
|
143
|
+
appUrl: config.appUrl,
|
|
144
|
+
...(config.sessionsUrl ? { sessionsUrl: config.sessionsUrl } : {}),
|
|
145
|
+
tenantId: tenant.tenantId,
|
|
146
|
+
tenantName: tenant.tenantName,
|
|
147
|
+
...(tenant.productAccess ? { productAccess: tenant.productAccess } : {}),
|
|
148
|
+
scopes: [...credential.scopes],
|
|
149
|
+
remoteRunId: state.runId,
|
|
150
|
+
updatedAt: new Date().toISOString(),
|
|
151
|
+
};
|
|
152
|
+
const selectedEnvironment = {
|
|
153
|
+
IMPEL_REMOTE_RUN_ID: state.runId,
|
|
154
|
+
IMPEL_REMOTE_DANGEROUS_MODE: "1",
|
|
155
|
+
};
|
|
156
|
+
for (const name of environmentNames) {
|
|
157
|
+
if (!ENV_NAME_RE.test(name)) throw new Error(`invalid environment variable name ${JSON.stringify(name)}`);
|
|
158
|
+
if (!Object.hasOwn(process.env, name)) throw new Error(`environment variable ${name} is not set`);
|
|
159
|
+
selectedEnvironment[name] = process.env[name];
|
|
160
|
+
}
|
|
161
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
162
|
+
const environmentFile = Object.entries(selectedEnvironment)
|
|
163
|
+
.map(([name, value]) => `export ${name}=${shellQuote(value)}`)
|
|
164
|
+
.join("\n") + "\n";
|
|
165
|
+
const profileRoot = `/home/agent/.config/impel/cli/tenants/${tenant.tenantId}`;
|
|
166
|
+
const claudeSettings = {
|
|
167
|
+
permissions: { defaultMode: "bypassPermissions" },
|
|
168
|
+
sandbox: { enabled: false },
|
|
169
|
+
};
|
|
170
|
+
const files = [
|
|
171
|
+
{ path: "/home/agent/.config/impel/config.json", contents: `${JSON.stringify(remoteConfig, null, 2)}\n` },
|
|
172
|
+
{ path: "/home/agent/.config/impel/remote/env.sh", contents: environmentFile },
|
|
173
|
+
{
|
|
174
|
+
path: "/home/agent/.config/impel/remote/run.json",
|
|
175
|
+
contents: `${JSON.stringify({
|
|
176
|
+
runId: state.runId,
|
|
177
|
+
credentialTokenId: credential.tokenId,
|
|
178
|
+
expiresAt: state.expiresAt,
|
|
179
|
+
}, null, 2)}\n`,
|
|
180
|
+
},
|
|
181
|
+
{ path: `${profileRoot}/claude/settings.json`, contents: `${JSON.stringify(claudeSettings, null, 2)}\n` },
|
|
182
|
+
];
|
|
183
|
+
remoteNode(state, String.raw`
|
|
184
|
+
const fs = require("node:fs");
|
|
185
|
+
const path = require("node:path");
|
|
186
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
187
|
+
for (const file of payload.files) {
|
|
188
|
+
const target = path.resolve(file.path);
|
|
189
|
+
if (!target.startsWith("/home/agent/.config/impel/")) throw new Error("refusing remote path");
|
|
190
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
191
|
+
fs.writeFileSync(target, file.contents, { mode: 0o600 });
|
|
192
|
+
fs.chmodSync(target, 0o600);
|
|
193
|
+
}
|
|
194
|
+
`, { files });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function uploadRepository(state, repository) {
|
|
198
|
+
const paths = runPaths(state.runId);
|
|
199
|
+
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `cat > ${REMOTE_BUNDLE}`), {
|
|
200
|
+
inputFile: paths.bundle,
|
|
201
|
+
});
|
|
202
|
+
remoteNode(state, String.raw`
|
|
203
|
+
const fs = require("node:fs");
|
|
204
|
+
const cp = require("node:child_process");
|
|
205
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
206
|
+
const repo = "/workspace/repo";
|
|
207
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
208
|
+
fs.mkdirSync("/workspace", { recursive: true });
|
|
209
|
+
cp.execFileSync("git", ["clone", "/tmp/impel-remote-repo.bundle", repo], { stdio: "inherit" });
|
|
210
|
+
cp.execFileSync("git", ["-C", repo, "checkout", "--detach", payload.head], { stdio: "inherit" });
|
|
211
|
+
if (payload.branch) cp.execFileSync("git", ["-C", repo, "checkout", "-B", payload.branch, payload.head], { stdio: "inherit" });
|
|
212
|
+
if (payload.origin) cp.execFileSync("git", ["-C", repo, "remote", "set-url", "origin", payload.origin], { stdio: "inherit" });
|
|
213
|
+
if (payload.userName) cp.execFileSync("git", ["-C", repo, "config", "user.name", payload.userName]);
|
|
214
|
+
if (payload.userEmail) cp.execFileSync("git", ["-C", repo, "config", "user.email", payload.userEmail]);
|
|
215
|
+
`, repository);
|
|
216
|
+
|
|
217
|
+
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${REMOTE_REPO}`), {
|
|
218
|
+
inputFile: paths.archive,
|
|
219
|
+
});
|
|
220
|
+
const deleted = fs.readFileSync(paths.deletedList);
|
|
221
|
+
remoteNode(state, String.raw`
|
|
222
|
+
const fs = require("node:fs");
|
|
223
|
+
const path = require("node:path");
|
|
224
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
225
|
+
const root = "/workspace/repo";
|
|
226
|
+
for (const relative of payload.deleted) {
|
|
227
|
+
const target = path.resolve(root, relative);
|
|
228
|
+
if (target !== root && !target.startsWith(root + path.sep)) throw new Error("unsafe deleted path");
|
|
229
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
fs.rmSync("/tmp/impel-remote-repo.bundle", { force: true });
|
|
232
|
+
`, { deleted: deleted.toString("utf8").split("\0").filter(Boolean) });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function prepareWorkspace(state, { installMode = "auto", setupCommand = null } = {}) {
|
|
236
|
+
if (!new Set(["auto", "skip"]).has(installMode)) throw new Error("--install must be auto or skip");
|
|
237
|
+
if (installMode === "auto") {
|
|
238
|
+
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", ["-t", state.alias, "impel-remote-prepare", REMOTE_REPO]);
|
|
239
|
+
}
|
|
240
|
+
if (setupCommand) {
|
|
241
|
+
const encodedCommand = Buffer.from(setupCommand, "utf8").toString("base64");
|
|
242
|
+
const source = "const cp=require('node:child_process');const command=Buffer.from(process.argv[1],'base64').toString('utf8');const child=cp.spawnSync('/bin/bash',['-lc',command],{cwd:'/workspace/repo',stdio:'inherit'});process.exit(Number.isInteger(child.status)?child.status:1)";
|
|
243
|
+
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", [
|
|
244
|
+
"-t", state.alias, `node -e ${remoteShellQuote(source)} ${remoteShellQuote(encodedCommand)}`,
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function collectSessionFiles(root, sessionId) {
|
|
250
|
+
const files = [];
|
|
251
|
+
const visit = (directory, relative = "") => {
|
|
252
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
253
|
+
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
254
|
+
const child = path.join(directory, entry.name);
|
|
255
|
+
if (entry.isSymbolicLink()) continue;
|
|
256
|
+
if (entry.isDirectory()) visit(child, childRelative);
|
|
257
|
+
else if (entry.isFile() && childRelative.includes(sessionId)) files.push(childRelative);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
if (fs.existsSync(root)) visit(root);
|
|
261
|
+
return files;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function transferSessionCheckpoint(state, { provider, sessionId, tenantId }) {
|
|
265
|
+
if (!SESSION_ID_RE.test(String(sessionId || ""))) throw new Error("--session must be a safe provider session id");
|
|
266
|
+
const profiles = tenantCliProfilePaths(tenantId);
|
|
267
|
+
const localRoot = provider === "claude" ? profiles.claudeConfigDir : profiles.codexHome;
|
|
268
|
+
const remoteRoot = provider === "claude"
|
|
269
|
+
? `/home/agent/.config/impel/cli/tenants/${tenantId}/claude`
|
|
270
|
+
: `/home/agent/.config/impel/cli/tenants/${tenantId}/codex`;
|
|
271
|
+
const files = collectSessionFiles(localRoot, sessionId);
|
|
272
|
+
if (files.length === 0) {
|
|
273
|
+
throw new Error(`could not find ${provider} session ${sessionId} in the selected Impel CLI profile`);
|
|
274
|
+
}
|
|
275
|
+
const checkpointList = path.join(runPaths(state.runId).root, `checkpoint-${provider}.list`);
|
|
276
|
+
const checkpointArchive = path.join(runPaths(state.runId).root, `checkpoint-${provider}.tar`);
|
|
277
|
+
writePrivate(checkpointList, Buffer.from(`${files.join("\0")}\0`, "utf8"));
|
|
278
|
+
try {
|
|
279
|
+
runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
|
|
280
|
+
"-cf", checkpointArchive, "--no-xattrs", "--null", "-T", checkpointList,
|
|
281
|
+
], { cwd: localRoot });
|
|
282
|
+
sshCapture(state, `mkdir -p ${remoteRoot}`);
|
|
283
|
+
runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${remoteRoot}`), {
|
|
284
|
+
inputFile: checkpointArchive,
|
|
285
|
+
});
|
|
286
|
+
} finally {
|
|
287
|
+
fs.rmSync(checkpointList, { force: true });
|
|
288
|
+
fs.rmSync(checkpointArchive, { force: true });
|
|
289
|
+
}
|
|
290
|
+
return files.length;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const REMOTE_PROJECT_ROOT = REMOTE_REPO;
|
package/src/verbatimRelay.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
export const VERBATIM_RELAY_OPT_IN_MARKER = "verbatimRelay enabled";
|
|
2
|
-
|
|
3
|
-
export const VERBATIM_SPAWN_REQUIREMENT = 'fork_turns="none"';
|
|
4
|
-
|
|
5
|
-
export const VERBATIM_FINAL_TEXT_CONSTRAINTS =
|
|
6
|
-
"no preface, rewriting, Markdown changes, or independent synthesis";
|
|
7
|
-
|
|
8
|
-
export function usesVerbatimRelay(agent) {
|
|
9
|
-
return agent?.verbatimRelay === true;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function parentVerbatimRelayAppendix() {
|
|
13
|
-
return (
|
|
14
|
-
`When an explicit custom agent's catalog-derived description declares that verbatimRelay is enabled, ` +
|
|
15
|
-
`spawn it with ${VERBATIM_SPAWN_REQUIREMENT} and relay its finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}; ` +
|
|
16
|
-
"preserve Sources sections and citations exactly. " +
|
|
17
|
-
"Custom agents without that declaration keep the default delegation behavior."
|
|
18
|
-
);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function customAgentVerbatimDescriptionLead() {
|
|
22
|
-
return (
|
|
23
|
-
`Explicit custom agent with ${VERBATIM_RELAY_OPT_IN_MARKER}: ` +
|
|
24
|
-
`callers must use ${VERBATIM_SPAWN_REQUIREMENT} and relay its result verbatim`
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function adapterCallerSpawnGuidance(clientLabel) {
|
|
29
|
-
return (
|
|
30
|
-
`Callers must spawn this explicit custom ${clientLabel} agent with ${VERBATIM_SPAWN_REQUIREMENT} ` +
|
|
31
|
-
"and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior."
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function adapterHardCompletionGuidance() {
|
|
36
|
-
return (
|
|
37
|
-
`After the orchestration completes, return its single text output verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
38
|
-
"A successful output is result.finalText exactly. A failure output preserves the durable runId, output, and error."
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function adapterSoftCompletionGuidance() {
|
|
43
|
-
return (
|
|
44
|
-
"After the orchestration completes, return its single text output as the answer. " +
|
|
45
|
-
"A successful output is result.finalText. A failure output preserves the durable runId, output, and error. " +
|
|
46
|
-
"Never invent or independently synthesize a replacement result."
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function claudeHardCompletionGuidance() {
|
|
51
|
-
return (
|
|
52
|
-
`When it succeeds, return result.finalText exactly with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
53
|
-
"When it fails, return the durable runId, preserved output, and error. " +
|
|
54
|
-
"Never invent or independently synthesize a replacement result."
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function claudeSoftCompletionGuidance() {
|
|
59
|
-
return (
|
|
60
|
-
"When it succeeds, return result.finalText faithfully as the answer. " +
|
|
61
|
-
"When it fails, return the durable runId, preserved output, and error. " +
|
|
62
|
-
"Never invent or independently synthesize a replacement result."
|
|
63
|
-
);
|
|
64
|
-
}
|