vault-go 0.11.0 → 0.13.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 +39 -0
- package/assets/vault-logo-glass.png +0 -0
- package/dist/activity.js +28 -13
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +7 -2
- package/dist/callback-page.d.ts +2 -2
- package/dist/callback-page.js +29 -4
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +56 -0
- package/dist/cloud.d.ts +11 -0
- package/dist/cloud.js +36 -2
- package/dist/context-engines.d.ts +60 -0
- package/dist/context-engines.js +546 -0
- package/dist/index.js +5 -1
- package/dist/local-dashboard.js +321 -22
- package/dist/local-device.d.ts +22 -0
- package/dist/local-device.js +185 -0
- package/dist/local-install.d.ts +8 -1
- package/dist/local-install.js +172 -59
- package/dist/local-service.d.ts +13 -3
- package/dist/local-service.js +506 -84
- package/dist/locale.d.ts +1 -1
- package/dist/locale.js +102 -5
- package/dist/private-store.d.ts +8 -0
- package/dist/private-store.js +130 -0
- package/dist/server.js +50 -0
- package/package.json +8 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { hostname, platform } from "node:os";
|
|
5
|
+
import { loadConfig, loadTokens, saveTokens } from "./config.js";
|
|
6
|
+
import { VaultCloudError } from "./cloud.js";
|
|
7
|
+
export function localDevice(home, cloud, storage, version) {
|
|
8
|
+
const owner = () => createHash("sha256")
|
|
9
|
+
.update(loadConfig(home).apiUrl + "|" + (loadConfig(home).email ?? ""))
|
|
10
|
+
.digest("hex");
|
|
11
|
+
let status = { status: "unregistered" };
|
|
12
|
+
let inFlight = null;
|
|
13
|
+
let stopped = false;
|
|
14
|
+
const record = () => {
|
|
15
|
+
const d = storage?.read("local-device.json");
|
|
16
|
+
return d?.owner === owner() ? d : null;
|
|
17
|
+
};
|
|
18
|
+
function adopt(value, requestOwner) {
|
|
19
|
+
const made = value;
|
|
20
|
+
if (!/^[a-f0-9-]{36}$/.test(made.id) ||
|
|
21
|
+
!/^vg_device_[a-f0-9]{64}$/.test(made.deviceToken) ||
|
|
22
|
+
typeof made.accessToken !== "string" ||
|
|
23
|
+
typeof made.refreshToken !== "string")
|
|
24
|
+
throw new Error("Invalid registration response.");
|
|
25
|
+
const device = {
|
|
26
|
+
id: made.id,
|
|
27
|
+
token: made.deviceToken,
|
|
28
|
+
owner: requestOwner,
|
|
29
|
+
};
|
|
30
|
+
storage.write("local-device.json", device);
|
|
31
|
+
saveTokens({
|
|
32
|
+
accessToken: made.accessToken,
|
|
33
|
+
refreshToken: made.refreshToken,
|
|
34
|
+
expiresIn: made.expiresIn,
|
|
35
|
+
expiresAt: Date.now() + (made.expiresIn || 900) * 1000,
|
|
36
|
+
}, home);
|
|
37
|
+
return device;
|
|
38
|
+
}
|
|
39
|
+
function installationId() {
|
|
40
|
+
const path = join(home, "local-installation.json");
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (/^[a-f0-9-]{36}$/.test(value.id))
|
|
44
|
+
return value.id;
|
|
45
|
+
throw new Error("Invalid installation identity.");
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error.code !== "ENOENT")
|
|
49
|
+
throw error;
|
|
50
|
+
const id = randomUUID();
|
|
51
|
+
writeFileSync(path, JSON.stringify({ id }), { mode: 0o600, flag: "wx" });
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function heartbeat() {
|
|
56
|
+
if (stopped)
|
|
57
|
+
return;
|
|
58
|
+
if (inFlight)
|
|
59
|
+
return inFlight;
|
|
60
|
+
if (!loadTokens(home) || !storage) {
|
|
61
|
+
status = {
|
|
62
|
+
status: "unregistered",
|
|
63
|
+
...(!storage ? { error: "cache_key_unavailable" } : {}),
|
|
64
|
+
};
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const requestOwner = owner();
|
|
68
|
+
inFlight = Promise.resolve().then(async () => {
|
|
69
|
+
try {
|
|
70
|
+
let device = record();
|
|
71
|
+
if (device?.revoked) {
|
|
72
|
+
status = { status: "revoked" };
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (device) {
|
|
76
|
+
let binding;
|
|
77
|
+
try {
|
|
78
|
+
binding = JSON.parse(Buffer.from(loadTokens(home).accessToken.split(".")[1], "base64url").toString()).deviceId;
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
if (binding !== device.id) {
|
|
82
|
+
const before = loadTokens(home)?.accessToken;
|
|
83
|
+
const made = await cloud.rotateDevice(device.id);
|
|
84
|
+
if (stopped ||
|
|
85
|
+
requestOwner !== owner() ||
|
|
86
|
+
before !== loadTokens(home)?.accessToken)
|
|
87
|
+
return;
|
|
88
|
+
device = adopt(made, requestOwner);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!device) {
|
|
92
|
+
const before = loadTokens(home);
|
|
93
|
+
const made = await cloud.registerDevice({
|
|
94
|
+
name: hostname().slice(0, 80),
|
|
95
|
+
platform: platform(),
|
|
96
|
+
installationId: installationId(),
|
|
97
|
+
agentVersion: version,
|
|
98
|
+
localProtection: "encrypted-cache",
|
|
99
|
+
});
|
|
100
|
+
if (stopped ||
|
|
101
|
+
requestOwner !== owner() ||
|
|
102
|
+
loadTokens(home)?.accessToken !== before?.accessToken)
|
|
103
|
+
return;
|
|
104
|
+
device = adopt(made, requestOwner);
|
|
105
|
+
}
|
|
106
|
+
const result = (await cloud.deviceAction(device.id, device.token, "heartbeat", { agentVersion: version, localProtection: "encrypted-cache" }));
|
|
107
|
+
if (stopped || requestOwner !== owner())
|
|
108
|
+
return;
|
|
109
|
+
status = {
|
|
110
|
+
status: "online",
|
|
111
|
+
...(result.name ? { name: result.name } : {}),
|
|
112
|
+
...(result.lastSeenAt ? { lastSeenAt: result.lastSeenAt } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (stopped || requestOwner !== owner())
|
|
117
|
+
return;
|
|
118
|
+
if (error instanceof VaultCloudError &&
|
|
119
|
+
(error.status === 401 || error.status === 409)) {
|
|
120
|
+
const current = record();
|
|
121
|
+
if (current)
|
|
122
|
+
storage.write("local-device.json", { ...current, revoked: true });
|
|
123
|
+
status = {
|
|
124
|
+
status: "revoked",
|
|
125
|
+
error: "device_reauthorization_required",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
status = {
|
|
130
|
+
status: "offline",
|
|
131
|
+
error: error instanceof VaultCloudError && error.status === 403
|
|
132
|
+
? "browser_reauthentication_required"
|
|
133
|
+
: "device_connection_unavailable",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
inFlight = null;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return inFlight;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
heartbeat,
|
|
144
|
+
close() {
|
|
145
|
+
stopped = true;
|
|
146
|
+
},
|
|
147
|
+
status: () => ({ ...status }),
|
|
148
|
+
async disconnect() {
|
|
149
|
+
await inFlight;
|
|
150
|
+
const current = record();
|
|
151
|
+
if (current && !current.revoked) {
|
|
152
|
+
try {
|
|
153
|
+
await cloud.deviceAction(current.id, current.token, "disconnect");
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!(error instanceof VaultCloudError && error.status === 401))
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (current)
|
|
161
|
+
storage?.write("local-device.json", { ...current, revoked: true });
|
|
162
|
+
status = { status: "revoked" };
|
|
163
|
+
},
|
|
164
|
+
// New browser authentication is explicit user consent to authorize this installation again.
|
|
165
|
+
async reauthorize() {
|
|
166
|
+
if (!storage)
|
|
167
|
+
throw new Error("Encrypted device credential storage is unavailable.");
|
|
168
|
+
await inFlight;
|
|
169
|
+
const current = record();
|
|
170
|
+
const requestOwner = owner(), before = loadTokens(home)?.accessToken;
|
|
171
|
+
if (current) {
|
|
172
|
+
const made = await cloud.rotateDevice(current.id);
|
|
173
|
+
if (stopped ||
|
|
174
|
+
requestOwner !== owner() ||
|
|
175
|
+
before !== loadTokens(home)?.accessToken)
|
|
176
|
+
return;
|
|
177
|
+
adopt(made, requestOwner);
|
|
178
|
+
}
|
|
179
|
+
else if (status.status === "revoked") {
|
|
180
|
+
writeFileSync(join(home, "local-installation.json"), JSON.stringify({ id: randomUUID() }), { mode: 0o600 });
|
|
181
|
+
}
|
|
182
|
+
await heartbeat();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/dist/local-install.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export declare const LOCAL_PORT = 38850;
|
|
2
2
|
export declare const localUrl = "http://localhost:38850/";
|
|
3
3
|
export interface LocalHealth {
|
|
4
|
-
service:
|
|
4
|
+
service: "vault-go";
|
|
5
5
|
version: string;
|
|
6
6
|
port: number;
|
|
7
7
|
}
|
|
@@ -11,7 +11,14 @@ export declare function startLocal(home?: string): Promise<LocalHealth>;
|
|
|
11
11
|
export declare function openLocal(home?: string): Promise<void>;
|
|
12
12
|
export declare function stopLocal(home?: string): Promise<boolean>;
|
|
13
13
|
export declare function launchAgentXml(executable: string, worker: string, home: string): string;
|
|
14
|
+
interface LaunchAgentOperations {
|
|
15
|
+
run: (args: string[]) => Promise<void>;
|
|
16
|
+
wait: (milliseconds: number) => Promise<void>;
|
|
17
|
+
report: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function replaceLaunchAgent(domain: string, path: string, prepare: () => Promise<void>, operations: LaunchAgentOperations): Promise<void>;
|
|
14
20
|
export declare function installLocal(home?: string): Promise<{
|
|
15
21
|
startup: boolean;
|
|
16
22
|
}>;
|
|
17
23
|
export declare function uninstallLocal(home?: string): Promise<void>;
|
|
24
|
+
export {};
|
package/dist/local-install.js
CHANGED
|
@@ -1,89 +1,120 @@
|
|
|
1
|
-
import { execFile, spawn } from
|
|
2
|
-
import { createHmac, randomBytes, timingSafeEqual } from
|
|
3
|
-
import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from
|
|
4
|
-
import { homedir } from
|
|
5
|
-
import { dirname, join } from
|
|
6
|
-
import { fileURLToPath } from
|
|
7
|
-
import { promisify } from
|
|
8
|
-
import { vaultHome } from
|
|
9
|
-
import { openVaultBrowser } from
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { vaultHome } from "./config.js";
|
|
9
|
+
import { openVaultBrowser } from "./auth.js";
|
|
10
10
|
const execute = promisify(execFile);
|
|
11
11
|
export const LOCAL_PORT = 38850;
|
|
12
|
-
const LABEL =
|
|
13
|
-
const OWNER =
|
|
12
|
+
const LABEL = "com.resolveup.vault-go";
|
|
13
|
+
const OWNER = "<!-- Managed by vault-go local install -->";
|
|
14
14
|
export const localUrl = `http://localhost:${LOCAL_PORT}/`;
|
|
15
15
|
export async function localStatus(port = LOCAL_PORT, home = vaultHome()) {
|
|
16
|
-
const challenge = randomBytes(32).toString(
|
|
16
|
+
const challenge = randomBytes(32).toString("base64url");
|
|
17
17
|
let response;
|
|
18
18
|
try {
|
|
19
|
-
response = await fetch(`http://127.0.0.1:${port}/health?challenge=${challenge}`, { signal: AbortSignal.timeout(1500), redirect:
|
|
19
|
+
response = await fetch(`http://127.0.0.1:${port}/health?challenge=${challenge}`, { signal: AbortSignal.timeout(1500), redirect: "error" });
|
|
20
20
|
}
|
|
21
21
|
catch {
|
|
22
22
|
return null;
|
|
23
23
|
}
|
|
24
24
|
let body;
|
|
25
25
|
try {
|
|
26
|
-
body = await response.json();
|
|
26
|
+
body = (await response.json());
|
|
27
27
|
}
|
|
28
28
|
catch {
|
|
29
29
|
throw new Error(`Port ${port} is occupied by another service.`);
|
|
30
30
|
}
|
|
31
|
-
if (!response.ok ||
|
|
31
|
+
if (!response.ok ||
|
|
32
|
+
body.service !== "vault-go" ||
|
|
33
|
+
typeof body.version !== "string" ||
|
|
34
|
+
!/^\d+\.\d+\.\d+/.test(body.version) ||
|
|
35
|
+
body.port !== port) {
|
|
32
36
|
throw new Error(`Port ${port} is occupied by another service.`);
|
|
33
37
|
}
|
|
34
|
-
const expected = createHmac(
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
const expected = createHmac("sha256", localToken(home))
|
|
39
|
+
.update(challenge)
|
|
40
|
+
.digest();
|
|
41
|
+
const received = Buffer.from(body.proof ?? "", "base64url");
|
|
42
|
+
if (expected.length !== received.length ||
|
|
43
|
+
!timingSafeEqual(expected, received))
|
|
37
44
|
throw new Error(`Could not verify the Vault service on port ${port}.`);
|
|
38
45
|
return { service: body.service, version: body.version, port: body.port };
|
|
39
46
|
}
|
|
40
47
|
export function stageLocalRuntime(home = vaultHome(), source = dirname(fileURLToPath(import.meta.url))) {
|
|
41
|
-
const manifest = JSON.parse(readFileSync(join(source,
|
|
42
|
-
const root = join(home,
|
|
43
|
-
const target = join(root,
|
|
48
|
+
const manifest = JSON.parse(readFileSync(join(source, "..", "package.json"), "utf8"));
|
|
49
|
+
const root = join(home, "local-runtime");
|
|
50
|
+
const target = join(root, "dist");
|
|
44
51
|
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
45
52
|
chmodSync(root, 0o700);
|
|
46
53
|
chmodSync(target, 0o700);
|
|
47
|
-
const excluded = new Set([
|
|
48
|
-
if (!existsSync(join(source,
|
|
49
|
-
throw new Error(
|
|
54
|
+
const excluded = new Set(["index.js", "server.js", "installer.js", "cli.js"]);
|
|
55
|
+
if (!existsSync(join(source, "local-worker.js")))
|
|
56
|
+
throw new Error("Local worker is missing. Build or reinstall vault-go first.");
|
|
50
57
|
for (const file of readdirSync(source)) {
|
|
51
|
-
if (!file.endsWith(
|
|
58
|
+
if (!file.endsWith(".js") ||
|
|
59
|
+
file.endsWith(".test.js") ||
|
|
60
|
+
excluded.has(file))
|
|
52
61
|
continue;
|
|
53
62
|
const destination = join(target, file);
|
|
54
63
|
copyFileSync(join(source, file), `${destination}.tmp`);
|
|
55
64
|
chmodSync(`${destination}.tmp`, 0o600);
|
|
56
65
|
renameSync(`${destination}.tmp`, destination);
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
const assets = join(root, "assets");
|
|
68
|
+
mkdirSync(assets, { recursive: true, mode: 0o700 });
|
|
69
|
+
const logo = join(source, "..", "assets", "vault-logo-glass.png");
|
|
70
|
+
if (existsSync(logo)) {
|
|
71
|
+
copyFileSync(logo, join(assets, "vault-logo-glass.png"));
|
|
72
|
+
chmodSync(join(assets, "vault-logo-glass.png"), 0o600);
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({
|
|
75
|
+
name: "vault-go-local-runtime",
|
|
76
|
+
version: manifest.version,
|
|
77
|
+
type: "module",
|
|
78
|
+
}), { mode: 0o600 });
|
|
79
|
+
return join(target, "local-worker.js");
|
|
60
80
|
}
|
|
61
81
|
export async function startLocal(home = vaultHome()) {
|
|
62
82
|
const running = await localStatus(LOCAL_PORT, home);
|
|
63
83
|
if (running)
|
|
64
84
|
return running;
|
|
65
85
|
const worker = stageLocalRuntime(home);
|
|
66
|
-
const log = openSync(join(home,
|
|
86
|
+
const log = openSync(join(home, "local-service.log"), "a", 0o600);
|
|
67
87
|
try {
|
|
68
|
-
const child = spawn(process.execPath, [worker], {
|
|
69
|
-
|
|
88
|
+
const child = spawn(process.execPath, [worker], {
|
|
89
|
+
detached: true,
|
|
90
|
+
stdio: ["ignore", log, log],
|
|
91
|
+
env: {
|
|
92
|
+
...process.env,
|
|
93
|
+
VAULT_GO_HOME: home,
|
|
94
|
+
VAULT_GO_LOCAL_PORT: String(LOCAL_PORT),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await new Promise((resolve, reject) => {
|
|
98
|
+
child.once("spawn", resolve);
|
|
99
|
+
child.once("error", reject);
|
|
100
|
+
});
|
|
70
101
|
child.unref();
|
|
71
102
|
}
|
|
72
103
|
finally {
|
|
73
104
|
closeSync(log);
|
|
74
105
|
}
|
|
75
106
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
76
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
77
108
|
const status = await localStatus(LOCAL_PORT, home);
|
|
78
109
|
if (status)
|
|
79
110
|
return status;
|
|
80
111
|
}
|
|
81
|
-
throw new Error(`Local service did not start. Check ${join(home,
|
|
112
|
+
throw new Error(`Local service did not start. Check ${join(home, "local-service.log")}.`);
|
|
82
113
|
}
|
|
83
114
|
function localToken(home) {
|
|
84
|
-
const value = JSON.parse(readFileSync(join(home,
|
|
115
|
+
const value = JSON.parse(readFileSync(join(home, "local-access.json"), "utf8"));
|
|
85
116
|
if (!value.token || !/^[A-Za-z0-9_-]{32,}$/.test(value.token))
|
|
86
|
-
throw new Error(
|
|
117
|
+
throw new Error("Local access token is missing or invalid.");
|
|
87
118
|
return value.token;
|
|
88
119
|
}
|
|
89
120
|
export async function openLocal(home = vaultHome()) {
|
|
@@ -92,32 +123,102 @@ export async function openLocal(home = vaultHome()) {
|
|
|
92
123
|
await openVaultBrowser(`${localUrl}#token=${encodeURIComponent(localToken(home))}`);
|
|
93
124
|
}
|
|
94
125
|
catch {
|
|
95
|
-
throw new Error(
|
|
126
|
+
throw new Error("Could not open the local dashboard. Retry: vault-go local open");
|
|
96
127
|
}
|
|
97
128
|
}
|
|
98
129
|
export async function stopLocal(home = vaultHome()) {
|
|
99
|
-
if (!await localStatus(LOCAL_PORT, home))
|
|
130
|
+
if (!(await localStatus(LOCAL_PORT, home)))
|
|
100
131
|
return false;
|
|
101
|
-
const result = await fetch(`http://127.0.0.1:${LOCAL_PORT}/api/shutdown`, {
|
|
132
|
+
const result = await fetch(`http://127.0.0.1:${LOCAL_PORT}/api/shutdown`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { Authorization: `Bearer ${localToken(home)}` },
|
|
135
|
+
signal: AbortSignal.timeout(3000),
|
|
136
|
+
redirect: "error",
|
|
137
|
+
});
|
|
102
138
|
if (!result.ok)
|
|
103
139
|
throw new Error(`Local shutdown refused (${result.status}).`);
|
|
104
140
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
105
|
-
if (!await localStatus(LOCAL_PORT, home))
|
|
141
|
+
if (!(await localStatus(LOCAL_PORT, home)))
|
|
106
142
|
return true;
|
|
107
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
108
144
|
}
|
|
109
|
-
throw new Error(
|
|
145
|
+
throw new Error("Local service has not stopped yet.");
|
|
110
146
|
}
|
|
111
147
|
export function launchAgentXml(executable, worker, home) {
|
|
112
|
-
const escape = (value) => value.replace(/[&<>"']/g, c => ({
|
|
113
|
-
|
|
148
|
+
const escape = (value) => value.replace(/[&<>"']/g, (c) => ({
|
|
149
|
+
"&": "&",
|
|
150
|
+
"<": "<",
|
|
151
|
+
">": ">",
|
|
152
|
+
'"': """,
|
|
153
|
+
"'": "'",
|
|
154
|
+
})[c]);
|
|
155
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${OWNER}\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${LABEL}</string><key>ProgramArguments</key><array><string>${escape(executable)}</string><string>${escape(worker)}</string></array><key>EnvironmentVariables</key><dict><key>VAULT_GO_HOME</key><string>${escape(home)}</string><key>VAULT_GO_LOCAL_PORT</key><string>${LOCAL_PORT}</string><key>PATH</key><string>${escape(process.env["PATH"] || "/usr/local/bin:/usr/bin:/bin")}</string></dict><key>RunAtLoad</key><true/><key>StandardOutPath</key><string>${escape(join(home, "local-service.log"))}</string><key>StandardErrorPath</key><string>${escape(join(home, "local-service.log"))}</string></dict></plist>\n`;
|
|
156
|
+
}
|
|
157
|
+
function agentPath() {
|
|
158
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
159
|
+
}
|
|
160
|
+
function assertOwned(path) {
|
|
161
|
+
if (existsSync(path) && !readFileSync(path, "utf8").includes(OWNER))
|
|
162
|
+
throw new Error(`Refusing to replace an unowned startup file: ${path}`);
|
|
163
|
+
}
|
|
164
|
+
export async function replaceLaunchAgent(domain, path, prepare, operations) {
|
|
165
|
+
const target = `${domain}/${LABEL}`;
|
|
166
|
+
const codeOf = (error) => error?.code;
|
|
167
|
+
const isAbsent = async () => {
|
|
168
|
+
try {
|
|
169
|
+
await operations.run(["print", target]);
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
// launchctl uses 113 for an unknown service. Other failures do not prove removal.
|
|
174
|
+
const stderr = String(error?.stderr ?? "");
|
|
175
|
+
if (codeOf(error) === 113 &&
|
|
176
|
+
stderr.includes(`Could not find service "${LABEL}"`))
|
|
177
|
+
return true;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
let bootoutError;
|
|
182
|
+
try {
|
|
183
|
+
await operations.run(["bootout", target]);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
bootoutError = error;
|
|
187
|
+
}
|
|
188
|
+
for (let attempt = 0;; attempt++) {
|
|
189
|
+
if (await isAbsent()) {
|
|
190
|
+
if (bootoutError &&
|
|
191
|
+
codeOf(bootoutError) !== 3 &&
|
|
192
|
+
codeOf(bootoutError) !== 113)
|
|
193
|
+
throw bootoutError;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (bootoutError)
|
|
197
|
+
throw bootoutError;
|
|
198
|
+
if (attempt === 39)
|
|
199
|
+
throw new Error(`LaunchAgent ${target} is still registered after bootout; bootstrap was not attempted.`);
|
|
200
|
+
await operations.wait(250);
|
|
201
|
+
}
|
|
202
|
+
await prepare();
|
|
203
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
204
|
+
try {
|
|
205
|
+
await operations.run(["bootstrap", domain, path]);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
// Error 5 can be a teardown race, but is also a genuine configuration failure.
|
|
210
|
+
// Retry only when print independently proves that no job was registered.
|
|
211
|
+
if (codeOf(error) !== 5 || attempt === 2 || !(await isAbsent()))
|
|
212
|
+
throw error;
|
|
213
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
214
|
+
operations.report(`launchctl bootstrap: ${message}\nConfirmed ${target} is absent; retrying (${attempt + 2}/3).`);
|
|
215
|
+
await operations.wait(500 * (attempt + 1));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
114
218
|
}
|
|
115
|
-
function agentPath() { return join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`); }
|
|
116
|
-
function assertOwned(path) { if (existsSync(path) && !readFileSync(path, 'utf8').includes(OWNER))
|
|
117
|
-
throw new Error(`Refusing to replace an unowned startup file: ${path}`); }
|
|
118
219
|
export async function installLocal(home = vaultHome()) {
|
|
119
220
|
const worker = stageLocalRuntime(home);
|
|
120
|
-
if (process.platform !==
|
|
221
|
+
if (process.platform !== "darwin") {
|
|
121
222
|
await startLocal(home);
|
|
122
223
|
return { startup: false };
|
|
123
224
|
}
|
|
@@ -126,34 +227,46 @@ export async function installLocal(home = vaultHome()) {
|
|
|
126
227
|
await localStatus(LOCAL_PORT, home);
|
|
127
228
|
mkdirSync(dirname(path), { recursive: true });
|
|
128
229
|
const domain = `gui/${process.getuid()}`;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
230
|
+
await replaceLaunchAgent(domain, path, async () => {
|
|
231
|
+
await stopLocal(home);
|
|
232
|
+
writeFileSync(path, launchAgentXml(process.execPath, worker, home), {
|
|
233
|
+
mode: 0o600,
|
|
234
|
+
});
|
|
235
|
+
}, {
|
|
236
|
+
run: async (args) => {
|
|
237
|
+
await execute("launchctl", args, { timeout: 5000 });
|
|
238
|
+
},
|
|
239
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
240
|
+
report: (message) => {
|
|
241
|
+
process.stderr.write(`${message}\n`);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
137
244
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
138
245
|
const health = await localStatus(LOCAL_PORT, home);
|
|
139
246
|
if (health)
|
|
140
247
|
return { startup: true };
|
|
141
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
142
249
|
}
|
|
143
|
-
throw new Error(
|
|
250
|
+
throw new Error("LaunchAgent was registered, but the local service did not start. Check local-service.log.");
|
|
144
251
|
}
|
|
145
252
|
export async function uninstallLocal(home = vaultHome()) {
|
|
146
|
-
if (process.platform ===
|
|
253
|
+
if (process.platform === "darwin") {
|
|
147
254
|
const path = agentPath();
|
|
148
255
|
assertOwned(path);
|
|
149
256
|
if (existsSync(path)) {
|
|
150
257
|
try {
|
|
151
|
-
await execute(
|
|
258
|
+
await execute("launchctl", [
|
|
259
|
+
"bootout",
|
|
260
|
+
`gui/${process.getuid()}/${LABEL}`,
|
|
261
|
+
]);
|
|
152
262
|
}
|
|
153
263
|
catch (error) {
|
|
154
264
|
// Only remove registration files after confirming the job is absent.
|
|
155
265
|
try {
|
|
156
|
-
await execute(
|
|
266
|
+
await execute("launchctl", [
|
|
267
|
+
"print",
|
|
268
|
+
`gui/${process.getuid()}/${LABEL}`,
|
|
269
|
+
]);
|
|
157
270
|
}
|
|
158
271
|
catch {
|
|
159
272
|
rmSync(path);
|
package/dist/local-service.d.ts
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
|
-
import { type IncomingMessage } from
|
|
2
|
-
import { VaultCloudClient } from
|
|
1
|
+
import { type IncomingMessage } from "node:http";
|
|
2
|
+
import { VaultCloudClient } from "./cloud.js";
|
|
3
|
+
import { authenticateWithBrowser } from "./auth.js";
|
|
4
|
+
import { localDevice } from "./local-device.js";
|
|
5
|
+
import { generateContext, listContextEngines } from "./context-engines.js";
|
|
3
6
|
export declare const LOCAL_PORT = 38850;
|
|
4
7
|
export declare function localAccessToken(home: string): string;
|
|
5
|
-
type LocalCloud = Pick<VaultCloudClient,
|
|
8
|
+
type LocalCloud = Pick<VaultCloudClient, "projects" | "feed" | "health">;
|
|
6
9
|
export declare function startLocalService(options?: {
|
|
7
10
|
home?: string;
|
|
8
11
|
port?: number;
|
|
9
12
|
cloud?: LocalCloud;
|
|
10
13
|
pollMs?: number;
|
|
14
|
+
authenticate?: typeof authenticateWithBrowser;
|
|
15
|
+
cacheKey?: Buffer;
|
|
16
|
+
device?: ReturnType<typeof localDevice> | false;
|
|
17
|
+
context?: {
|
|
18
|
+
list: () => ReturnType<typeof listContextEngines>;
|
|
19
|
+
generate: typeof generateContext;
|
|
20
|
+
};
|
|
11
21
|
}): Promise<{
|
|
12
22
|
server: import("http").Server<typeof IncomingMessage, typeof import("http").ServerResponse>;
|
|
13
23
|
port: number;
|