repoburg 1.3.217 → 1.3.219
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/agent-pane/frontend/dist/assets/index-BUkOj2Cb.js +392 -0
- package/agent-pane/frontend/dist/assets/index-gAFaOmjN.css +1 -0
- package/agent-pane/frontend/dist/index.html +19 -0
- package/daemon/dist/agentPane.js +13 -8
- package/daemon/dist/auth/authManager.js +9 -0
- package/daemon/dist/index.js +32 -5
- package/daemon/dist/middleware/checkRemoteAuth.js +72 -0
- package/daemon/dist/middleware/checkRemoteAuth.spec.js +117 -0
- package/daemon/dist/middleware/corsOrigin.js +53 -0
- package/daemon/dist/middleware/corsOrigin.spec.js +46 -0
- package/daemon/dist/remote/access-jwt.js +67 -0
- package/daemon/dist/remote/access-jwt.spec.js +94 -0
- package/daemon/dist/remote/cloudflared.js +239 -0
- package/daemon/dist/remote/cloudflared.spec.js +117 -0
- package/daemon/dist/remote/orchestrator.js +245 -0
- package/daemon/dist/remote/orchestrator.spec.js +192 -0
- package/daemon/dist/remote/probes.js +59 -0
- package/daemon/dist/remote/provisioner.js +86 -0
- package/daemon/dist/remote/provisioner.spec.js +104 -0
- package/daemon/dist/remote/routes.js +155 -0
- package/daemon/dist/remote/routes.spec.js +245 -0
- package/daemon/dist/remote/tunnel-state.js +54 -0
- package/daemon/dist/remote/tunnel-state.spec.js +40 -0
- package/daemon/dist/staticServing.js +44 -0
- package/daemon/dist/staticServing.spec.js +85 -0
- package/package.json +2 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getEnableJob = getEnableJob;
|
|
7
|
+
exports.startEnableJob = startEnableJob;
|
|
8
|
+
exports.runEnable = runEnable;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic remote-setup orchestration.
|
|
11
|
+
*
|
|
12
|
+
* The daemon itself runs the enable state machine (detect → provision →
|
|
13
|
+
* install → verify) in the background; the modal only polls GET /remote/enable
|
|
14
|
+
* and renders the progress. Idempotent by design:
|
|
15
|
+
* - tunnel already provisioned → provision is skipped,
|
|
16
|
+
* - cloudflared binary already on disk → download is skipped,
|
|
17
|
+
* - service already running with live edge connections → everything is
|
|
18
|
+
* skipped (the job goes straight to ready).
|
|
19
|
+
*
|
|
20
|
+
* When a deterministic step fails, the orchestrator opens a troubleshooting
|
|
21
|
+
* SESSION on the embedded pane (last used provider/model/cwd combo) with the
|
|
22
|
+
* diagnostics inlined — the agent then works the problem in the background
|
|
23
|
+
* while the modal shows "Troubleshooting". No SKILL.md is involved; the
|
|
24
|
+
* troubleshooting playbook lives in this file.
|
|
25
|
+
*/
|
|
26
|
+
const os_1 = __importDefault(require("os"));
|
|
27
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
28
|
+
const provisioner_1 = require("./provisioner");
|
|
29
|
+
const cloudflared_1 = require("./cloudflared");
|
|
30
|
+
const probes_1 = require("./probes");
|
|
31
|
+
const DEFAULT_PANE_BASE_URL = 'http://127.0.0.1:9998';
|
|
32
|
+
const DEFAULT_VERIFY_TIMEOUT_MS = 90000;
|
|
33
|
+
const DEFAULT_VERIFY_INTERVAL_MS = 5000;
|
|
34
|
+
/** The single active (or last finished) job — one enable at a time. */
|
|
35
|
+
let currentJob = null;
|
|
36
|
+
function getEnableJob() {
|
|
37
|
+
return currentJob;
|
|
38
|
+
}
|
|
39
|
+
function log(job, step, message) {
|
|
40
|
+
job.step = step;
|
|
41
|
+
job.logs.push({ at: new Date().toISOString(), step, message });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Kick off the enable job in the background. Throws when a job is already
|
|
45
|
+
* running. Returns the job snapshot the caller can poll.
|
|
46
|
+
*/
|
|
47
|
+
function startEnableJob(mahlas, deps = {}) {
|
|
48
|
+
if (currentJob?.state === 'running') {
|
|
49
|
+
throw Object.assign(new Error('An enable job is already running.'), { statusCode: 409 });
|
|
50
|
+
}
|
|
51
|
+
const job = {
|
|
52
|
+
id: `enable-${Date.now()}`,
|
|
53
|
+
mahlas,
|
|
54
|
+
hostname: null,
|
|
55
|
+
state: 'running',
|
|
56
|
+
step: 'detect',
|
|
57
|
+
error: null,
|
|
58
|
+
troubleshootingSessionId: null,
|
|
59
|
+
logs: [],
|
|
60
|
+
startedAt: new Date().toISOString(),
|
|
61
|
+
finishedAt: null,
|
|
62
|
+
};
|
|
63
|
+
currentJob = job;
|
|
64
|
+
void runEnable(job, deps).catch((error) => {
|
|
65
|
+
// Catastrophic path — runEnable should never reject, but never leave
|
|
66
|
+
// the job stuck in 'running'.
|
|
67
|
+
finishFailed(job, error?.message ?? 'Unknown error');
|
|
68
|
+
});
|
|
69
|
+
return job;
|
|
70
|
+
}
|
|
71
|
+
function finishFailed(job, error) {
|
|
72
|
+
job.state = 'failed';
|
|
73
|
+
job.step = 'failed';
|
|
74
|
+
job.error = error;
|
|
75
|
+
job.finishedAt = new Date().toISOString();
|
|
76
|
+
}
|
|
77
|
+
/** The deterministic state machine. */
|
|
78
|
+
async function runEnable(job, depOverrides = {}) {
|
|
79
|
+
const d = {
|
|
80
|
+
loadTunnelState: tunnel_state_1.loadTunnelState,
|
|
81
|
+
provisionTunnel: provisioner_1.provisionTunnel,
|
|
82
|
+
isInstalled: cloudflared_1.isInstalled,
|
|
83
|
+
downloadCloudflared: cloudflared_1.downloadCloudflared,
|
|
84
|
+
installService: cloudflared_1.installService,
|
|
85
|
+
isServiceRunning: cloudflared_1.isServiceRunning,
|
|
86
|
+
isConnected: probes_1.probeTunnelConnected,
|
|
87
|
+
paneBaseUrl: DEFAULT_PANE_BASE_URL,
|
|
88
|
+
fetchImpl: fetch,
|
|
89
|
+
verifyTimeoutMs: DEFAULT_VERIFY_TIMEOUT_MS,
|
|
90
|
+
verifyIntervalMs: DEFAULT_VERIFY_INTERVAL_MS,
|
|
91
|
+
waitImpl: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
92
|
+
...depOverrides,
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
// ---- detect: idempotent no-op check ----
|
|
96
|
+
log(job, 'detect', 'Checking the current machine state.');
|
|
97
|
+
const existing = d.loadTunnelState();
|
|
98
|
+
const running = await d.isServiceRunning().catch(() => false);
|
|
99
|
+
const connected = await d.isConnected();
|
|
100
|
+
if (existing && running && connected) {
|
|
101
|
+
job.hostname = existing.hostname;
|
|
102
|
+
log(job, 'ready', `Already remote and connected as ${existing.hostname}. Nothing to do.`);
|
|
103
|
+
job.state = 'ready';
|
|
104
|
+
job.finishedAt = new Date().toISOString();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// ---- provision (only when no local state exists) ----
|
|
108
|
+
let state = existing;
|
|
109
|
+
if (!state) {
|
|
110
|
+
log(job, 'provision', `Provisioning the tunnel as "${job.mahlas ?? ''}".`);
|
|
111
|
+
state = await d.provisionTunnel(job.mahlas ?? '');
|
|
112
|
+
job.hostname = state.hostname;
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
job.hostname = state.hostname;
|
|
116
|
+
log(job, 'provision', `Tunnel already provisioned (${state.hostname}) — skipping provisioning.`);
|
|
117
|
+
}
|
|
118
|
+
// ---- install (only when the service is not running) ----
|
|
119
|
+
if (!running) {
|
|
120
|
+
log(job, 'install', 'Installing the cloudflared service.');
|
|
121
|
+
if (d.isInstalled()) {
|
|
122
|
+
log(job, 'install', 'cloudflared binary already present — skipping the download.');
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
log(job, 'install', 'Downloading cloudflared (sha256-verified).');
|
|
126
|
+
await d.downloadCloudflared();
|
|
127
|
+
}
|
|
128
|
+
await d.installService(state.tunnelToken);
|
|
129
|
+
log(job, 'install', 'Service installed and started.');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
log(job, 'install', 'Service already running — skipping install.');
|
|
133
|
+
}
|
|
134
|
+
// ---- verify: live edge connections with a bounded wait ----
|
|
135
|
+
log(job, 'verify', `Waiting for live edge connections (up to ${Math.round(d.verifyTimeoutMs / 1000)}s).`);
|
|
136
|
+
const deadline = Date.now() + d.verifyTimeoutMs;
|
|
137
|
+
for (;;) {
|
|
138
|
+
const ok = (await d.isConnected()) && (await d.isServiceRunning().catch(() => false));
|
|
139
|
+
if (ok) {
|
|
140
|
+
log(job, 'ready', `Machine is reachable at https://${state.hostname}`);
|
|
141
|
+
job.state = 'ready';
|
|
142
|
+
job.finishedAt = new Date().toISOString();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (Date.now() >= deadline) {
|
|
146
|
+
throw new Error('Tunnel did not connect in time — see the cloudflared service logs.');
|
|
147
|
+
}
|
|
148
|
+
await d.waitImpl(d.verifyIntervalMs);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
// Deterministic path failed → background troubleshooting via the pane.
|
|
153
|
+
const message = error?.message ?? 'Unknown error';
|
|
154
|
+
job.error = message;
|
|
155
|
+
log(job, 'troubleshooting', `Deterministic setup failed: ${message}`);
|
|
156
|
+
try {
|
|
157
|
+
await startTroubleshootingSession(job, d);
|
|
158
|
+
}
|
|
159
|
+
catch (troubleshootError) {
|
|
160
|
+
finishFailed(job, `${message} | troubleshooting failed: ${troubleshootError.message}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Open a troubleshooting session with the LAST USED provider/model/cwd combo
|
|
166
|
+
* and submit the diagnostics prompt. The agent runs autonomously in the
|
|
167
|
+
* background; the modal surfaces the session.
|
|
168
|
+
*/
|
|
169
|
+
async function startTroubleshootingSession(job, d) {
|
|
170
|
+
const base = d.paneBaseUrl;
|
|
171
|
+
const http = d.fetchImpl;
|
|
172
|
+
// The last used agent: newest session wins (GET /api/sessions is newest-first).
|
|
173
|
+
const listRes = await http(`${base}/api/sessions`);
|
|
174
|
+
if (!listRes.ok)
|
|
175
|
+
throw new Error(`pane sessions list failed: ${listRes.status}`);
|
|
176
|
+
const sessions = (await listRes.json());
|
|
177
|
+
const last = sessions[0];
|
|
178
|
+
if (!last) {
|
|
179
|
+
throw new Error('no agent sessions on this machine — nothing to troubleshoot with');
|
|
180
|
+
}
|
|
181
|
+
const createRes = await http(`${base}/api/sessions`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
title: 'Remote Setup Troubleshooting',
|
|
186
|
+
runtime_type: last.runtime_type ?? 'codex',
|
|
187
|
+
model: last.model ?? null,
|
|
188
|
+
model_reasoning_effort: last.model_reasoning_effort ?? null,
|
|
189
|
+
cwd: last.cwd ?? null,
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
if (!createRes.ok)
|
|
193
|
+
throw new Error(`pane session create failed: ${createRes.status}`);
|
|
194
|
+
const session = (await createRes.json());
|
|
195
|
+
const turnRes = await http(`${base}/api/sessions/${session.id}/turns`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
198
|
+
body: JSON.stringify({ user_prompt: buildTroubleshootingPrompt(job) }),
|
|
199
|
+
});
|
|
200
|
+
if (!turnRes.ok)
|
|
201
|
+
throw new Error(`pane turn submit failed: ${turnRes.status}`);
|
|
202
|
+
job.troubleshootingSessionId = session.id;
|
|
203
|
+
job.state = 'troubleshooting';
|
|
204
|
+
job.finishedAt = new Date().toISOString();
|
|
205
|
+
log(job, 'troubleshooting', `Troubleshooting session opened (${session.id}) — the agent is working.`);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Inline diagnostics + playbook for the troubleshooting agent.
|
|
209
|
+
* SECURITY: never include the tunnel token or any other credential here.
|
|
210
|
+
*/
|
|
211
|
+
function buildTroubleshootingPrompt(job) {
|
|
212
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
213
|
+
const tail = job.logs
|
|
214
|
+
.slice(-12)
|
|
215
|
+
.map((entry) => `[${entry.step}] ${entry.message}`)
|
|
216
|
+
.join('\n');
|
|
217
|
+
const platform = os_1.default.platform();
|
|
218
|
+
const isMac = platform === 'darwin';
|
|
219
|
+
return [
|
|
220
|
+
'The deterministic remote-setup orchestration on this machine FAILED. Fix it autonomously.',
|
|
221
|
+
'',
|
|
222
|
+
`Error: ${job.error ?? 'unknown'}`,
|
|
223
|
+
state
|
|
224
|
+
? `Machine: mahlas=${state.mahlas}, hostname=${state.hostname}`
|
|
225
|
+
: 'Machine: no tunnel state on disk (provisioning may have failed).',
|
|
226
|
+
`Platform: ${platform}`,
|
|
227
|
+
'',
|
|
228
|
+
'Setup log:',
|
|
229
|
+
tail,
|
|
230
|
+
'',
|
|
231
|
+
'Playbook:',
|
|
232
|
+
'1. Snapshot: curl http://localhost:9998/remote/status',
|
|
233
|
+
isMac
|
|
234
|
+
? '2. Service: launchctl list | grep repoburg ; log: ~/.repoburg/bin/cloudflared.log ; plist: ~/Library/LaunchAgents/com.repoburg.cloudflared.plist'
|
|
235
|
+
: '2. Service: systemctl --user status repoburg-cloudflared ; journalctl --user -u repoburg-cloudflared',
|
|
236
|
+
'3. Fix the root cause yourself:',
|
|
237
|
+
' - reinstall the service: curl -X POST http://localhost:9998/remote/install',
|
|
238
|
+
' - broken tunnel: curl -X DELETE http://localhost:9998/remote/uninstall then curl -X POST -H "Content-Type: application/json" -d \'{"mahlas":"<mahlas>"}\' http://localhost:9998/remote/provision',
|
|
239
|
+
' - the mahlas may be taken (409) — pick a close variant and tell the user.',
|
|
240
|
+
'4. Done when /remote/status reports connection.ok=true and https://<hostname>/health answers.',
|
|
241
|
+
'5. Stop after 3 failed attempts (~2 minutes) and report findings. Do not loop forever.',
|
|
242
|
+
'',
|
|
243
|
+
'Safety: /remote/* works ONLY from localhost curl. Never edit ~/.repoburg/remote.json by hand and never run cloudflared manually. Never print the tunnel token.',
|
|
244
|
+
].join('\n');
|
|
245
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrator spec — drives runEnable with mock deps; startEnableJob is
|
|
5
|
+
* covered by the routes spec (mahlas validation + busy 409).
|
|
6
|
+
*/
|
|
7
|
+
const orchestrator_1 = require("./orchestrator");
|
|
8
|
+
jest.mock('./tunnel-state');
|
|
9
|
+
jest.mock('./provisioner');
|
|
10
|
+
jest.mock('./cloudflared');
|
|
11
|
+
jest.mock('./probes');
|
|
12
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
13
|
+
const cloudflared_1 = require("./cloudflared");
|
|
14
|
+
const probes_1 = require("./probes");
|
|
15
|
+
const mockedLoad = tunnel_state_1.loadTunnelState;
|
|
16
|
+
const mockedIsInstalled = cloudflared_1.isInstalled;
|
|
17
|
+
const mockedConnected = probes_1.probeTunnelConnected;
|
|
18
|
+
const STATE = {
|
|
19
|
+
mahlas: 'zz',
|
|
20
|
+
hostname: 'zz.repoburg.com',
|
|
21
|
+
accountTunnelId: 'acc-1',
|
|
22
|
+
tunnelToken: 'TUNNELSECRET',
|
|
23
|
+
accessTeamDomain: 'team.cloudflareaccess.com',
|
|
24
|
+
accessAud: 'aud',
|
|
25
|
+
ownerEmail: 'o@x.com',
|
|
26
|
+
installedAt: '2026-01-01T00:00:00Z',
|
|
27
|
+
};
|
|
28
|
+
/** Wait for a settled microtask chain (the job loop is async). */
|
|
29
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
30
|
+
function makeJob(mahlas = 'newmach') {
|
|
31
|
+
return {
|
|
32
|
+
id: 'job-1',
|
|
33
|
+
mahlas,
|
|
34
|
+
hostname: null,
|
|
35
|
+
state: 'running',
|
|
36
|
+
step: 'detect',
|
|
37
|
+
error: null,
|
|
38
|
+
troubleshootingSessionId: null,
|
|
39
|
+
logs: [],
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
finishedAt: null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function makeDeps(overrides = {}) {
|
|
45
|
+
return {
|
|
46
|
+
loadTunnelState: mockedLoad,
|
|
47
|
+
provisionTunnel: jest.fn().mockResolvedValue(STATE),
|
|
48
|
+
isInstalled: mockedIsInstalled,
|
|
49
|
+
downloadCloudflared: jest.fn().mockResolvedValue('/bin/cloudflared'),
|
|
50
|
+
installService: jest.fn().mockResolvedValue('/service'),
|
|
51
|
+
isServiceRunning: jest.fn().mockResolvedValue(true),
|
|
52
|
+
isConnected: mockedConnected,
|
|
53
|
+
paneBaseUrl: 'http://127.0.0.1:9998',
|
|
54
|
+
fetchImpl: jest.fn(),
|
|
55
|
+
verifyTimeoutMs: 60,
|
|
56
|
+
verifyIntervalMs: 10,
|
|
57
|
+
waitImpl: jest.fn(async () => undefined),
|
|
58
|
+
...overrides,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
describe('orchestrator: deterministic happy path', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
});
|
|
65
|
+
it('no-ops when already provisioned, running, and connected', async () => {
|
|
66
|
+
mockedLoad.mockReturnValue(STATE);
|
|
67
|
+
const deps = makeDeps();
|
|
68
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
69
|
+
mockedConnected.mockResolvedValue(true);
|
|
70
|
+
const job = makeJob();
|
|
71
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
72
|
+
expect(job.state).toBe('ready');
|
|
73
|
+
expect(deps.provisionTunnel).not.toHaveBeenCalled();
|
|
74
|
+
expect(deps.installService).not.toHaveBeenCalled();
|
|
75
|
+
expect(job.logs.some((l) => l.message.includes('Nothing to do'))).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
it('provisions, installs, and verifies a fresh machine', async () => {
|
|
78
|
+
mockedLoad.mockReturnValue(null);
|
|
79
|
+
mockedIsInstalled.mockReturnValue(false);
|
|
80
|
+
mockedConnected.mockResolvedValue(true);
|
|
81
|
+
// Detect: service down (fresh machine). Verify: service up after install.
|
|
82
|
+
const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
83
|
+
const job = makeJob('fresh');
|
|
84
|
+
const deps = makeDeps({ isServiceRunning: serviceProbe });
|
|
85
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
86
|
+
expect(deps.provisionTunnel).toHaveBeenCalledWith('fresh');
|
|
87
|
+
expect(deps.downloadCloudflared).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
|
|
89
|
+
expect(job.state).toBe('ready');
|
|
90
|
+
expect(job.logs.some((l) => l.message.includes('reachable at https://zz.repoburg.com'))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
it('skips provision when the tunnel state exists and skips download when the binary is present', async () => {
|
|
93
|
+
mockedLoad.mockReturnValue(STATE);
|
|
94
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
95
|
+
mockedConnected.mockResolvedValue(true);
|
|
96
|
+
// Detect: service down → install runs. Verify: service up.
|
|
97
|
+
const serviceProbe = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
98
|
+
const job = makeJob('ignored');
|
|
99
|
+
const deps = makeDeps({ isServiceRunning: serviceProbe });
|
|
100
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
101
|
+
expect(deps.provisionTunnel).not.toHaveBeenCalled();
|
|
102
|
+
expect(deps.downloadCloudflared).not.toHaveBeenCalled();
|
|
103
|
+
expect(deps.installService).toHaveBeenCalledWith('TUNNELSECRET');
|
|
104
|
+
expect(job.state).toBe('ready');
|
|
105
|
+
});
|
|
106
|
+
it('skips install when the service is already running and waits for connections', async () => {
|
|
107
|
+
mockedLoad.mockReturnValue(STATE);
|
|
108
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
109
|
+
// First probe: not connected (service warming up); second: connected.
|
|
110
|
+
mockedConnected.mockResolvedValueOnce(false).mockResolvedValue(true);
|
|
111
|
+
const job = makeJob();
|
|
112
|
+
const deps = makeDeps();
|
|
113
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
114
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
115
|
+
expect(deps.installService).not.toHaveBeenCalled();
|
|
116
|
+
expect(job.state).toBe('ready');
|
|
117
|
+
});
|
|
118
|
+
it('fails after the verify deadline', async () => {
|
|
119
|
+
mockedLoad.mockReturnValue(STATE);
|
|
120
|
+
mockedIsInstalled.mockReturnValue(true);
|
|
121
|
+
mockedConnected.mockResolvedValue(false);
|
|
122
|
+
const job = makeJob();
|
|
123
|
+
const deps = makeDeps();
|
|
124
|
+
deps.isServiceRunning.mockResolvedValue(true);
|
|
125
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
126
|
+
expect(job.state).toBe('failed'); // no pane sessions available in the mock
|
|
127
|
+
expect(job.error).toContain('did not connect');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('orchestrator: troubleshooting fallback', () => {
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
jest.clearAllMocks();
|
|
133
|
+
mockedLoad.mockReturnValue(null);
|
|
134
|
+
mockedIsInstalled.mockReturnValue(false);
|
|
135
|
+
mockedConnected.mockResolvedValue(false);
|
|
136
|
+
});
|
|
137
|
+
function paneFetch(jestFn) {
|
|
138
|
+
// /api/sessions list (newest first), session create, turn submit.
|
|
139
|
+
jestFn.mockImplementation(async (input, init) => {
|
|
140
|
+
const url = String(input);
|
|
141
|
+
if (url.endsWith('/api/sessions') && (!init || init.method === 'GET')) {
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
json: async () => [
|
|
145
|
+
{ id: 's1', runtime_type: 'repoburg', model: 'glm', model_reasoning_effort: null, cwd: '/repo' },
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (url.endsWith('/api/sessions') && init?.method === 'POST') {
|
|
150
|
+
return { ok: true, json: async () => ({ id: 'ts-1' }) };
|
|
151
|
+
}
|
|
152
|
+
if (url.includes('/api/sessions/ts-1/turns')) {
|
|
153
|
+
return { ok: true, json: async () => ({ id: 'turn-1' }) };
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`unexpected ${url}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
it('opens a troubleshooting session with the last used agent on failure', async () => {
|
|
159
|
+
const fetchImpl = jest.fn();
|
|
160
|
+
paneFetch(fetchImpl);
|
|
161
|
+
const deps = makeDeps({ fetchImpl: fetchImpl });
|
|
162
|
+
const job = makeJob();
|
|
163
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
164
|
+
expect(job.state).toBe('troubleshooting');
|
|
165
|
+
expect(job.troubleshootingSessionId).toBe('ts-1');
|
|
166
|
+
// Session created with the LAST USED combo.
|
|
167
|
+
const createCall = fetchImpl.mock.calls.find((c) => c[1]?.method === 'POST' && String(c[0]).endsWith('/api/sessions'));
|
|
168
|
+
expect(JSON.parse(createCall[1].body)).toMatchObject({
|
|
169
|
+
title: 'Remote Setup Troubleshooting',
|
|
170
|
+
runtime_type: 'repoburg',
|
|
171
|
+
model: 'glm',
|
|
172
|
+
cwd: '/repo',
|
|
173
|
+
});
|
|
174
|
+
// The prompt carries the playbook but never the tunnel token.
|
|
175
|
+
const turnCall = fetchImpl.mock.calls.find((c) => String(c[0]).includes('/api/sessions/ts-1/turns'));
|
|
176
|
+
const prompt = JSON.parse(turnCall[1].body).user_prompt;
|
|
177
|
+
expect(prompt).toContain('localhost:9998/remote/status');
|
|
178
|
+
expect(prompt).not.toContain('TUNNELSECRET');
|
|
179
|
+
});
|
|
180
|
+
it('fails (no agent) when the pane has no sessions', async () => {
|
|
181
|
+
const fetchImpl = jest.fn(async () => ({ ok: true, json: async () => [] }));
|
|
182
|
+
const deps = makeDeps({ fetchImpl: fetchImpl });
|
|
183
|
+
const job = makeJob();
|
|
184
|
+
await (0, orchestrator_1.runEnable)(job, deps);
|
|
185
|
+
expect(job.state).toBe('failed');
|
|
186
|
+
expect(job.troubleshootingSessionId).toBeNull();
|
|
187
|
+
expect(job.error).toContain('no agent sessions');
|
|
188
|
+
});
|
|
189
|
+
it('getEnableJob returns null before any job', () => {
|
|
190
|
+
expect((0, orchestrator_1.getEnableJob)()).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.probePublicHealth = probePublicHealth;
|
|
4
|
+
exports.probeTunnelConnected = probeTunnelConnected;
|
|
5
|
+
/**
|
|
6
|
+
* Connectivity probes shared by the /remote routes and the enable
|
|
7
|
+
* orchestrator. Both signals are needed: the public health probe only proves
|
|
8
|
+
* the edge knows the hostname (CF Access answers 302 before the tunnel is
|
|
9
|
+
* consulted), while the cloudflared metrics gauge proves live edge
|
|
10
|
+
* connections through the tunnel.
|
|
11
|
+
*/
|
|
12
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
13
|
+
const cloudflared_1 = require("./cloudflared");
|
|
14
|
+
const HEALTH_CHECK_TIMEOUT_MS = 10000;
|
|
15
|
+
function publicHealthUrl() {
|
|
16
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
17
|
+
return state ? `https://${state.hostname}/health` : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Edge reachability probe. Behind CF Access an anonymous request gets a 302
|
|
21
|
+
* to the login page BEFORE the tunnel is ever consulted — so this can only
|
|
22
|
+
* prove the edge knows the hostname, never that the tunnel is connected.
|
|
23
|
+
* `redirect: 'manual'` prevents the old false positive where the login
|
|
24
|
+
* page's 200 was counted as a healthy daemon.
|
|
25
|
+
*/
|
|
26
|
+
async function probePublicHealth(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
27
|
+
const url = publicHealthUrl();
|
|
28
|
+
if (!url)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
32
|
+
return response.status >= 200 && response.status < 400;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Tunnel connectivity from cloudflared's LOCAL metrics endpoint. This is the
|
|
40
|
+
* primary "is the tunnel actually serving" signal: `ha_connections` counts
|
|
41
|
+
* live edge connections (0 or unreachable metrics = not connected).
|
|
42
|
+
*/
|
|
43
|
+
async function probeTunnelConnected(timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch((0, cloudflared_1.cloudflaredMetricsUrl)(), {
|
|
46
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok)
|
|
49
|
+
return false;
|
|
50
|
+
const body = await response.text();
|
|
51
|
+
// The gauge may appear label-less (`… 4`) or labeled (`…{region="us"} 4`);
|
|
52
|
+
// ^… anchors past the HELP/TYPE lines.
|
|
53
|
+
const match = body.match(/^cloudflared_tunnel_ha_connections(?:\{[^}]*\})?\s+([0-9.]+)/m);
|
|
54
|
+
return Boolean(match && Number(match[1]) > 0);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProvisioningError = void 0;
|
|
4
|
+
exports.accountServerUrl = accountServerUrl;
|
|
5
|
+
exports.provisionTunnel = provisionTunnel;
|
|
6
|
+
exports.listTunnels = listTunnels;
|
|
7
|
+
exports.uninstallTunnel = uninstallTunnel;
|
|
8
|
+
/**
|
|
9
|
+
* Account-server client for tunnel lifecycle. The daemon authenticates with
|
|
10
|
+
* the device JWT from `repoburg login` (~/.repoburg/token.jwt).
|
|
11
|
+
*/
|
|
12
|
+
const authManager_1 = require("../auth/authManager");
|
|
13
|
+
const tunnel_state_1 = require("./tunnel-state");
|
|
14
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
15
|
+
const DEFAULT_ACCOUNT_SERVER_URL = isDevelopment
|
|
16
|
+
? 'http://localhost:2999'
|
|
17
|
+
: 'https://repoburg.com/api/account';
|
|
18
|
+
function accountServerUrl() {
|
|
19
|
+
return process.env.ACCOUNT_SERVER_URL ?? DEFAULT_ACCOUNT_SERVER_URL;
|
|
20
|
+
}
|
|
21
|
+
class ProvisioningError extends Error {
|
|
22
|
+
constructor(message, statusCode) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.statusCode = statusCode;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.ProvisioningError = ProvisioningError;
|
|
28
|
+
async function callAccountServer(method, path, token, body) {
|
|
29
|
+
const response = await fetch(`${accountServerUrl()}${path}`, {
|
|
30
|
+
method,
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${token}`,
|
|
33
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
34
|
+
},
|
|
35
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
36
|
+
});
|
|
37
|
+
const data = (await response.json().catch(() => ({})));
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const message = data.message ?? data.error ?? response.statusText;
|
|
40
|
+
throw new ProvisioningError(`Account server: ${message}`, response.status);
|
|
41
|
+
}
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Provision a new tunnel for this machine and persist the state locally.
|
|
46
|
+
* The response's tunnel_token is the only credential written to disk.
|
|
47
|
+
*/
|
|
48
|
+
async function provisionTunnel(mahlas) {
|
|
49
|
+
const token = await authManager_1.authManager.readToken();
|
|
50
|
+
if (!token) {
|
|
51
|
+
throw new ProvisioningError('Not logged in. Run `repoburg login` first.', 401);
|
|
52
|
+
}
|
|
53
|
+
const result = await callAccountServer('POST', '/device/tunnels', token, { mahlas });
|
|
54
|
+
// Access config + owner email arrive in the same response: the tunnel is
|
|
55
|
+
// email-gated from birth, and checkRemoteAuth can pin the owner's email.
|
|
56
|
+
const state = {
|
|
57
|
+
mahlas: result.mahlas,
|
|
58
|
+
hostname: result.hostname,
|
|
59
|
+
accountTunnelId: result.id,
|
|
60
|
+
tunnelToken: result.tunnel_token,
|
|
61
|
+
accessTeamDomain: result.accessTeamDomain,
|
|
62
|
+
accessAud: result.accessAud,
|
|
63
|
+
ownerEmail: result.ownerEmail,
|
|
64
|
+
installedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
(0, tunnel_state_1.saveTunnelState)(state);
|
|
67
|
+
return state;
|
|
68
|
+
}
|
|
69
|
+
/** List this user's machines (used by the pane machine list when local). */
|
|
70
|
+
async function listTunnels() {
|
|
71
|
+
const token = await authManager_1.authManager.readToken();
|
|
72
|
+
if (!token)
|
|
73
|
+
return [];
|
|
74
|
+
return callAccountServer('GET', '/device/tunnels', token);
|
|
75
|
+
}
|
|
76
|
+
/** Remove the tunnel on the account server and wipe the local state. */
|
|
77
|
+
async function uninstallTunnel() {
|
|
78
|
+
const state = (0, tunnel_state_1.loadTunnelState)();
|
|
79
|
+
if (!state)
|
|
80
|
+
return;
|
|
81
|
+
const token = await authManager_1.authManager.readToken();
|
|
82
|
+
if (token) {
|
|
83
|
+
await callAccountServer('DELETE', `/device/tunnels/${state.accountTunnelId}`, token);
|
|
84
|
+
}
|
|
85
|
+
(0, tunnel_state_1.clearTunnelState)();
|
|
86
|
+
}
|