@galda/cli 0.10.120 → 0.10.122
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/app/index.html +46 -0
- package/docs/SECURITY.md +8 -8
- package/engine/analytics-client.mjs +32 -2
- package/engine/analytics-pref.mjs +67 -0
- package/engine/relay-pref.mjs +41 -0
- package/engine/server.mjs +57 -9
- package/package.json +1 -1
package/app/index.html
CHANGED
|
@@ -12843,6 +12843,7 @@ function gearSetHTML(){
|
|
|
12843
12843
|
<div class="srow"><div class="sl">About Galda<span class="sd">What it is, what the agent can touch, and what leaves this machine</span></div>
|
|
12844
12844
|
<button class="optchip" data-gabout="1">Open</button></div>
|
|
12845
12845
|
${remoteAccessRow()}
|
|
12846
|
+
${shareUsageDataRow()}
|
|
12846
12847
|
${stability}
|
|
12847
12848
|
${gearRulesHTML()}
|
|
12848
12849
|
${hostReadinessRow()}
|
|
@@ -12953,6 +12954,46 @@ function remoteAccessRow(){
|
|
|
12953
12954
|
<button class="optchip${on ? ' on' : ''}" data-grelay="on">On</button>
|
|
12954
12955
|
<button class="optchip${on ? '' : ' on'}" data-grelay="off">Off</button></div>`;
|
|
12955
12956
|
}
|
|
12957
|
+
// "Share usage data" — the usage-analytics switch (engine: /api/analytics-pref).
|
|
12958
|
+
//
|
|
12959
|
+
// The label is not a choice: data-controls.html tells people to look for
|
|
12960
|
+
// Settings → Privacy → "Share usage data", and until 2026-08-02 that string
|
|
12961
|
+
// appeared nowhere in this file. Renaming the row means editing that document
|
|
12962
|
+
// in the same breath.
|
|
12963
|
+
//
|
|
12964
|
+
// Sits directly under Remote access because they are the same kind of promise,
|
|
12965
|
+
// and because one overrides the other: with the relay off nothing is sent at
|
|
12966
|
+
// all (docs/SECURITY.md), so the chips are shown as off and out of service —
|
|
12967
|
+
// a switch that controls nothing must not look live (§3 段階開示).
|
|
12968
|
+
function shareUsageDataRow(){
|
|
12969
|
+
const ap = state.analyticsPref;
|
|
12970
|
+
if (!ap || !ap.configured) return ''; // nothing configured to send to
|
|
12971
|
+
const relayOff = ap.relayEnabled === false;
|
|
12972
|
+
const on = ap.enabled === true;
|
|
12973
|
+
const desc = relayOff
|
|
12974
|
+
? 'Off — remote access is off, so nothing is sent at all.'
|
|
12975
|
+
: on
|
|
12976
|
+
? 'On — a few product events (a task started, finished, failed). Never your prompts, code, or file paths.'
|
|
12977
|
+
: 'Off — no usage events are sent.';
|
|
12978
|
+
const dis = relayOff ? ' disabled' : '';
|
|
12979
|
+
return `<div class="srow"><div class="sl">Share usage data<span class="sd">${esc(desc)}</span></div>
|
|
12980
|
+
<button class="optchip${on ? ' on' : ''}" data-ganalytics="on"${dis}>On</button>
|
|
12981
|
+
<button class="optchip${on ? '' : ' on'}" data-ganalytics="off"${dis}>Off</button></div>`;
|
|
12982
|
+
}
|
|
12983
|
+
async function loadAnalyticsPref(){
|
|
12984
|
+
try { const r = await fetch(withKey('/api/analytics-pref')); state.analyticsPref = r.ok ? await r.json() : null; }
|
|
12985
|
+
catch { state.analyticsPref = null; }
|
|
12986
|
+
if ($('gearPop') && custTab === 'settings') buildGearPop();
|
|
12987
|
+
}
|
|
12988
|
+
async function saveAnalyticsPref(enabled){
|
|
12989
|
+
const r = await fetch(withKey('/api/analytics-pref'), {
|
|
12990
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
12991
|
+
body: JSON.stringify({ enabled }),
|
|
12992
|
+
});
|
|
12993
|
+
if (!r.ok) return showErr('Failed to save.');
|
|
12994
|
+
state.analyticsPref = await r.json();
|
|
12995
|
+
if ($('gearPop').classList.contains('open')) buildGearPop();
|
|
12996
|
+
}
|
|
12956
12997
|
async function loadRelayPref(){
|
|
12957
12998
|
try { const r = await fetch(withKey('/api/relay-pref')); state.relayPref = r.ok ? await r.json() : null; }
|
|
12958
12999
|
catch { state.relayPref = null; }
|
|
@@ -12965,6 +13006,9 @@ async function saveRelayPref(enabled){
|
|
|
12965
13006
|
});
|
|
12966
13007
|
if (!r.ok) return showErr('Failed to save.');
|
|
12967
13008
|
state.relayPref = await r.json();
|
|
13009
|
+
// Remote access decides whether anything is sent at all, so the row below it
|
|
13010
|
+
// is now saying something different — ask the engine rather than guess.
|
|
13011
|
+
await loadAnalyticsPref();
|
|
12968
13012
|
if ($('gearPop').classList.contains('open')) buildGearPop();
|
|
12969
13013
|
}
|
|
12970
13014
|
async function loadStability(){
|
|
@@ -13059,6 +13103,7 @@ function buildGearPop(){
|
|
|
13059
13103
|
wireGearCommon(pop);
|
|
13060
13104
|
for (const b of pop.querySelectorAll('[data-gslang]')) b.onclick = () => saveLang(b.dataset.gslang);
|
|
13061
13105
|
for (const b of pop.querySelectorAll('[data-grelay]')) b.onclick = () => saveRelayPref(b.dataset.grelay === 'on');
|
|
13106
|
+
for (const b of pop.querySelectorAll('[data-ganalytics]')) b.onclick = () => saveAnalyticsPref(b.dataset.ganalytics === 'on');
|
|
13062
13107
|
for (const b of pop.querySelectorAll('[data-gabout]')) b.onclick = () => openAbout();
|
|
13063
13108
|
const signBtn = pop.querySelector('[data-gsignin]');
|
|
13064
13109
|
if (signBtn) signBtn.onclick = async () => {
|
|
@@ -13086,6 +13131,7 @@ function buildGearPop(){
|
|
|
13086
13131
|
loadProjectRules();
|
|
13087
13132
|
if (!state.stability) loadStability();
|
|
13088
13133
|
if (!state.relayPref) loadRelayPref();
|
|
13134
|
+
if (!state.analyticsPref) loadAnalyticsPref();
|
|
13089
13135
|
return;
|
|
13090
13136
|
}
|
|
13091
13137
|
if (custTab === 'mcp') {
|
package/docs/SECURITY.md
CHANGED
|
@@ -154,17 +154,17 @@ Each carries a timestamp and a pseudonymous account id, and nothing else.
|
|
|
154
154
|
- Repository names or paths
|
|
155
155
|
- Proof screenshots and recordings
|
|
156
156
|
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
usage would invalidate every other claim on this page, so it does not.
|
|
157
|
+
**Analytics do not follow the relay switch today.** Turning remote access off
|
|
158
|
+
stops the tunnel, but the product events above are still sent. If you want
|
|
159
|
+
zero analytics, start Galda with the endpoint unset:
|
|
160
|
+
`MANAGER_BILLING_API_URL= npx @galda/cli`. The client is a no-op without it
|
|
161
|
+
(`engine/analytics-client.mjs`). An in-app off switch is being implemented,
|
|
162
|
+
and this section will be updated when it ships.
|
|
164
163
|
|
|
165
164
|
## Reporting a vulnerability
|
|
166
165
|
|
|
167
|
-
|
|
166
|
+
Write to hello@galda.app. Please include what you did, what you saw, and the
|
|
167
|
+
version of Galda you were running.
|
|
168
168
|
|
|
169
169
|
## 日本語
|
|
170
170
|
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
// (docs/DATA-RETENTION-PLAN.md). Posts a single Tier-A event to the billing-api
|
|
3
3
|
// /events endpoint.
|
|
4
4
|
//
|
|
5
|
+
// OFF SWITCH: `emitEvent` sends nothing when the person has turned "Share usage
|
|
6
|
+
// data" off, and nothing when remote access is off — see analytics-pref.mjs for
|
|
7
|
+
// why the second one is not optional.
|
|
8
|
+
//
|
|
5
9
|
// PRIVACY: NEVER pass prompt / task text / file paths. Only an event type, a
|
|
6
10
|
// user identifier (the endpoint hashes it into a pseudonymous id), and
|
|
7
11
|
// allow-listed non-sensitive meta (project_hash / plan / status / model /
|
|
@@ -18,16 +22,42 @@
|
|
|
18
22
|
// task_interrupted → engine, task.status = 'interrupted'
|
|
19
23
|
// review_approved / review_dismissed → on the review action
|
|
20
24
|
|
|
25
|
+
import { readAnalyticsPref, resolveAnalyticsEnabled } from './analytics-pref.mjs';
|
|
26
|
+
import { readRelayPref, resolveRemoteAccess, relayClientRunning } from './relay-pref.mjs';
|
|
27
|
+
|
|
21
28
|
const BILLING_API_URL = (process.env.MANAGER_BILLING_API_URL ?? '').replace(/\/$/, '');
|
|
22
29
|
|
|
30
|
+
// Where the two preference files live, and what the relay is configured with.
|
|
31
|
+
// Supplied by whoever owns those (server.mjs at boot) rather than recomputed
|
|
32
|
+
// here: DATA_DIR has fallbacks this module has no business duplicating, and a
|
|
33
|
+
// second copy of that logic is how a switch ends up reading a file nobody writes.
|
|
34
|
+
let prefContext = { dataDir: null, relayUrl: process.env.RELAY_URL ?? '' };
|
|
35
|
+
export function setAnalyticsContext({ dataDir, relayUrl } = {}) {
|
|
36
|
+
prefContext = {
|
|
37
|
+
dataDir: dataDir ?? prefContext.dataDir,
|
|
38
|
+
relayUrl: relayUrl ?? prefContext.relayUrl,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Read at every event, not cached: a person who has just turned the switch off
|
|
43
|
+
// expects the next thing they do not to be reported, and a cache would make that
|
|
44
|
+
// depend on when the process last restarted. Events are rare (a task starting,
|
|
45
|
+
// a task finishing), so two small file reads cost nothing worth keeping.
|
|
23
46
|
export function analyticsEnabled() {
|
|
24
|
-
|
|
47
|
+
const { dataDir, relayUrl } = prefContext;
|
|
48
|
+
return resolveAnalyticsEnabled({
|
|
49
|
+
configured: Boolean(BILLING_API_URL),
|
|
50
|
+
pref: dataDir ? readAnalyticsPref(dataDir) : null,
|
|
51
|
+
relayEnabled: dataDir
|
|
52
|
+
? resolveRemoteAccess({ relayUrl, pref: readRelayPref(dataDir), relayRunning: relayClientRunning(dataDir) }).enabled
|
|
53
|
+
: undefined,
|
|
54
|
+
});
|
|
25
55
|
}
|
|
26
56
|
|
|
27
57
|
// type: one of the EVENT_TYPES; uid: account email/id (hashed server-side);
|
|
28
58
|
// meta: optional allow-listed non-sensitive fields only.
|
|
29
59
|
export function emitEvent(type, uid, meta) {
|
|
30
|
-
if (!
|
|
60
|
+
if (!analyticsEnabled()) return; // unconfigured, switched off, or relay off — no-op
|
|
31
61
|
try {
|
|
32
62
|
fetch(`${BILLING_API_URL}/events`, {
|
|
33
63
|
method: 'POST',
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Agent Manager — usage-analytics preference (the "Share usage data" switch)
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS: two published documents already promised this switch and the
|
|
4
|
+
// product did not have it (found 2026-08-02).
|
|
5
|
+
//
|
|
6
|
+
// docs/SECURITY.md — "With the relay off, nothing is collected. Not reduced,
|
|
7
|
+
// not anonymized: nothing."
|
|
8
|
+
// data-controls.html — "Product telemetry … can be turned off completely in
|
|
9
|
+
// Settings → Privacy → 'Share usage data'."
|
|
10
|
+
//
|
|
11
|
+
// Neither was true. `emitEvent` was guarded by `if (!BILLING_API_URL) return`
|
|
12
|
+
// and nothing else: it never looked at the relay preference, and the setting the
|
|
13
|
+
// second document names by string did not exist anywhere in the app. A promise
|
|
14
|
+
// in a security document that the code does not keep is worse than no promise —
|
|
15
|
+
// it is the one thing a person cannot check for themselves.
|
|
16
|
+
//
|
|
17
|
+
// Deliberately the same shape as relay-pref.mjs (a file in DATA_DIR, read at
|
|
18
|
+
// use, default ON), because these two switches are read together and a person
|
|
19
|
+
// setting one will look for the other in the same place.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
export const ANALYTICS_PREF_FILE = 'analytics-pref.json';
|
|
25
|
+
|
|
26
|
+
export function analyticsPrefPath(dataDir) {
|
|
27
|
+
return join(dataDir, ANALYTICS_PREF_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Should a usage event be sent? Pure — no clock, no I/O, no env.
|
|
31
|
+
//
|
|
32
|
+
// configured : is there a billing/analytics endpoint at all (BILLING_API_URL)
|
|
33
|
+
// pref : the user's saved choice (true | false | null when never set)
|
|
34
|
+
// relayEnabled : is remote access on (resolveRelayEnabled)
|
|
35
|
+
//
|
|
36
|
+
// Default is ON: a fresh install reports the handful of Tier-A events described
|
|
37
|
+
// in docs/DATA-RETENTION-PLAN.md. Only an explicit `false` turns it off, so a
|
|
38
|
+
// corrupt or missing preference file can never silently disable it either way.
|
|
39
|
+
//
|
|
40
|
+
// Relay off wins over everything. SECURITY.md sells that mode as "nothing leaves
|
|
41
|
+
// your machine", so a usage event escaping it would invalidate every other claim
|
|
42
|
+
// on that page. Someone who turns off remote access has not opted into analytics
|
|
43
|
+
// by omission — they have said the opposite in the plainest way the product offers.
|
|
44
|
+
export function resolveAnalyticsEnabled({ configured, pref, relayEnabled } = {}) {
|
|
45
|
+
if (!configured) return false;
|
|
46
|
+
if (relayEnabled === false) return false;
|
|
47
|
+
return pref !== false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read the saved preference. Returns null when the user has never chosen, so
|
|
51
|
+
// callers can tell "unset" from "explicitly on".
|
|
52
|
+
export function readAnalyticsPref(dataDir) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = JSON.parse(readFileSync(analyticsPrefPath(dataDir), 'utf8'));
|
|
55
|
+
return typeof raw?.enabled === 'boolean' ? raw.enabled : null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null; // never set, unreadable, or corrupt — all mean "no choice made"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Save the preference. Throws on a real write failure so the endpoint can report
|
|
62
|
+
// it rather than telling the user it saved something it did not.
|
|
63
|
+
export function writeAnalyticsPref(dataDir, enabled) {
|
|
64
|
+
const value = enabled === true;
|
|
65
|
+
writeFileSync(analyticsPrefPath(dataDir), JSON.stringify({ enabled: value, at: Date.now() }) + '\n');
|
|
66
|
+
return value;
|
|
67
|
+
}
|
package/engine/relay-pref.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
|
|
20
20
|
export const RELAY_PREF_FILE = 'relay-pref.json';
|
|
21
|
+
export const RELAY_LOCK_FILE = 'relay-client.lock';
|
|
21
22
|
|
|
22
23
|
export function relayPrefPath(dataDir) {
|
|
23
24
|
return join(dataDir, RELAY_PREF_FILE);
|
|
@@ -55,3 +56,43 @@ export function writeRelayPref(dataDir, enabled) {
|
|
|
55
56
|
writeFileSync(relayPrefPath(dataDir), JSON.stringify({ enabled: value, at: Date.now() }) + '\n');
|
|
56
57
|
return value;
|
|
57
58
|
}
|
|
59
|
+
|
|
60
|
+
// Is remote access actually on, from the server's point of view?
|
|
61
|
+
//
|
|
62
|
+
// `resolveRelayEnabled` above answers the LAUNCHER's question — "should I start a
|
|
63
|
+
// relay?" — from the URL it was configured with. The server cannot use the same
|
|
64
|
+
// answer, because it does not always have that env var: Masa's resident instance
|
|
65
|
+
// is launched by launchd as `node engine/server.mjs`, not through
|
|
66
|
+
// `bin/manager-for-ai.mjs` which is where RELAY_URL gets set. On 2026-08-02 that
|
|
67
|
+
// made the server report `relayEnabled:false` while a relay-client was connected
|
|
68
|
+
// and serving app.galda.app — so Settings said "remote access is off" to someone
|
|
69
|
+
// whose remote access was on, and the analytics gate (analytics-pref.mjs) went
|
|
70
|
+
// quiet on that install for a reason that was not true.
|
|
71
|
+
//
|
|
72
|
+
// A running relay-client is the ground truth: it holds a PID lock in the same
|
|
73
|
+
// DATA_DIR, which is what bin/manager-for-ai.mjs already checks for its
|
|
74
|
+
// duplicate-instance guard. Env is the fallback, not the authority.
|
|
75
|
+
//
|
|
76
|
+
// pref === false → off. The person said so, and that beats any evidence.
|
|
77
|
+
// otherwise → on if a relay is running OR one is configured.
|
|
78
|
+
export function resolveRemoteAccess({ relayUrl, pref, relayRunning } = {}) {
|
|
79
|
+
const configured = Boolean(relayUrl) || Boolean(relayRunning);
|
|
80
|
+
return { configured, enabled: configured && pref !== false };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Read the relay-client's PID lock and ask the OS whether that process is alive.
|
|
84
|
+
// `isAlive` is injected so the decision stays testable without spawning anything;
|
|
85
|
+
// callers pass a real probe (see relayClientRunning below).
|
|
86
|
+
export function relayLockIsLive(raw, isAlive) {
|
|
87
|
+
let lock;
|
|
88
|
+
try { lock = JSON.parse(String(raw ?? '')); } catch { return false; }
|
|
89
|
+
const pid = Number(lock?.pid);
|
|
90
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
91
|
+
try { return Boolean(isAlive(pid)); } catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function relayClientRunning(dataDir) {
|
|
95
|
+
let raw = '';
|
|
96
|
+
try { raw = readFileSync(join(dataDir, RELAY_LOCK_FILE), 'utf8'); } catch { return false; }
|
|
97
|
+
return relayLockIsLive(raw, (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } });
|
|
98
|
+
}
|
package/engine/server.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { createServer } from 'node:http';
|
|
15
15
|
import { spawn, execFile, spawnSync } from 'node:child_process';
|
|
16
16
|
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, statSync, readdirSync, unlinkSync, rmSync } from 'node:fs';
|
|
17
|
-
import { emitEvent } from './analytics-client.mjs';
|
|
17
|
+
import { emitEvent, setAnalyticsContext } from './analytics-client.mjs';
|
|
18
18
|
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
19
19
|
import { resolve, dirname, join, basename } from 'node:path';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
@@ -24,7 +24,8 @@ import { bootBanner, bootDestination, folderLabel, chooseFolderScript, parseChos
|
|
|
24
24
|
import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, classifyWorkerFailure, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildReviewAttemptLedger, latestGoalOutcomeTask, buildDirtyWorkspacePrBlock, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, rebasePreviewUrl, shouldCreateGoalPR, normalizePullRequestUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, DEFAULT_PRIORITY, normalizePriority, isAcceptedPriority, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, resolveTestGate, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, buildQueueWaits, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, autoFoldReviewsEnabled, parseGitUnifiedDiffLocations, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyPrSafety, isInconclusiveTestRun, countInfraFlakes, classifyRunFailures, classifyVerifyGate, buildVerificationInfraError, verificationInfraErrorFrom, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, licenseTokenPayload, shouldRefreshLicense, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, testFailureReason, manualTestRetryPrompt, isNothingVerifiable, isNothingVerifiableForPR, isSuspiciouslyIncompleteDone, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, shouldUsePlannerForGoal, RUN_DECL_FILE, REQUIREMENT_EVIDENCE_FILE, parseRequirementEvidenceDocument, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, buildGoalMessage, serializeGoalMessage, parseGoalMessages, computeInternalQualityMetrics, classifyInternalQualityTaskError, resolveWorkspaceMode, goalSessionFor, setGoalAgentSession, switchGoalAgentSession, parseApprovalRequest } from './lib.mjs';
|
|
25
25
|
import { migrateRequirementModel, validateRequirementModel } from './requirement-model.mjs';
|
|
26
26
|
import { buildManagerVerificationChecks, buildRequirementVerificationEvidence, mergeRequirementVerificationEvidence } from './requirement-verification.mjs';
|
|
27
|
-
import { readRelayPref, writeRelayPref,
|
|
27
|
+
import { readRelayPref, writeRelayPref, resolveRemoteAccess, relayClientRunning } from './relay-pref.mjs';
|
|
28
|
+
import { readAnalyticsPref, writeAnalyticsPref, resolveAnalyticsEnabled } from './analytics-pref.mjs';
|
|
28
29
|
import { createSerialQueue } from './lib.mjs';
|
|
29
30
|
import { wantsPullRequest } from './lib.mjs';
|
|
30
31
|
import { parseRequirementEvidence, unmappedChangedFiles, previewOpensSomethingElse } from './lib.mjs';
|
|
@@ -43,6 +44,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
43
44
|
const DATA_DIR = process.env.MANAGER_HOME
|
|
44
45
|
?? (existsSync(join(ROOT, '.git')) ? join(ROOT, 'engine') : join(homedir(), '.manager-for-ai'));
|
|
45
46
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
47
|
+
// The analytics client reads the "Share usage data" and remote-access
|
|
48
|
+
// preferences out of DATA_DIR before every event; it is told where that is
|
|
49
|
+
// rather than recomputing the fallbacks above.
|
|
50
|
+
setAnalyticsContext({ dataDir: DATA_DIR, relayUrl: process.env.RELAY_URL ?? '' });
|
|
46
51
|
// MANAGER_PORT=0 asks the OS for a free port; the real one is only known once
|
|
47
52
|
// the listen below succeeds, so this is rebound there (before anything prints
|
|
48
53
|
// or links to it).
|
|
@@ -4458,6 +4463,16 @@ const server = createServer(async (req, res) => {
|
|
|
4458
4463
|
return;
|
|
4459
4464
|
}
|
|
4460
4465
|
|
|
4466
|
+
// What the server can honestly say about remote access. NOT `RELAY_URL` alone:
|
|
4467
|
+
// launchd starts this process as `node engine/server.mjs`, so the variable
|
|
4468
|
+
// bin/manager-for-ai.mjs sets is simply absent there while a relay-client is
|
|
4469
|
+
// connected and serving app.galda.app. A live relay lock is the ground truth.
|
|
4470
|
+
const remoteAccess = () => resolveRemoteAccess({
|
|
4471
|
+
relayUrl: process.env.RELAY_URL,
|
|
4472
|
+
pref: readRelayPref(DATA_DIR),
|
|
4473
|
+
relayRunning: relayClientRunning(DATA_DIR),
|
|
4474
|
+
});
|
|
4475
|
+
|
|
4461
4476
|
// GET /api/relay-status — is this account's relay slot held by ANOTHER device?
|
|
4462
4477
|
// The relay-client (a separate process) writes relay-status.json when it stands
|
|
4463
4478
|
// down after being evicted (see relay-client.mjs / lib.mjs relayReconnectDecision).
|
|
@@ -4485,11 +4500,8 @@ const server = createServer(async (req, res) => {
|
|
|
4485
4500
|
// relay-takeover.req above.
|
|
4486
4501
|
if (url.pathname === '/api/relay-pref' && req.method === 'GET') {
|
|
4487
4502
|
const pref = readRelayPref(DATA_DIR);
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
enabled: resolveRelayEnabled({ relayUrl: process.env.RELAY_URL, pref }),
|
|
4491
|
-
configured: Boolean(process.env.RELAY_URL),
|
|
4492
|
-
});
|
|
4503
|
+
const ra = remoteAccess();
|
|
4504
|
+
return json(res, 200, { pref, enabled: ra.enabled, configured: ra.configured });
|
|
4493
4505
|
}
|
|
4494
4506
|
if (url.pathname === '/api/relay-pref' && req.method === 'POST') {
|
|
4495
4507
|
let body = '';
|
|
@@ -4503,10 +4515,46 @@ const server = createServer(async (req, res) => {
|
|
|
4503
4515
|
let saved;
|
|
4504
4516
|
try { saved = writeRelayPref(DATA_DIR, input.enabled); }
|
|
4505
4517
|
catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
|
|
4518
|
+
const ra = resolveRemoteAccess({ relayUrl: process.env.RELAY_URL, pref: saved, relayRunning: relayClientRunning(DATA_DIR) });
|
|
4519
|
+
return json(res, 200, { pref: saved, enabled: ra.enabled, configured: ra.configured });
|
|
4520
|
+
});
|
|
4521
|
+
return;
|
|
4522
|
+
}
|
|
4523
|
+
// The "Share usage data" switch (docs/SECURITY.md, data-controls.html). Same
|
|
4524
|
+
// shape as /api/relay-pref above, because they are the same kind of promise and
|
|
4525
|
+
// the settings panel shows them together. `enabled` is what actually happens —
|
|
4526
|
+
// it is false when analytics are unconfigured OR remote access is off, whatever
|
|
4527
|
+
// this preference says, so the UI can never show "on" for something that sends
|
|
4528
|
+
// nothing.
|
|
4529
|
+
if (url.pathname === '/api/analytics-pref' && req.method === 'GET') {
|
|
4530
|
+
const pref = readAnalyticsPref(DATA_DIR);
|
|
4531
|
+
const relayEnabled = remoteAccess().enabled;
|
|
4532
|
+
return json(res, 200, {
|
|
4533
|
+
pref,
|
|
4534
|
+
enabled: resolveAnalyticsEnabled({ configured: Boolean(BILLING_API_URL), pref, relayEnabled }),
|
|
4535
|
+
configured: Boolean(BILLING_API_URL),
|
|
4536
|
+
relayEnabled,
|
|
4537
|
+
});
|
|
4538
|
+
}
|
|
4539
|
+
if (url.pathname === '/api/analytics-pref' && req.method === 'POST') {
|
|
4540
|
+
let body = '';
|
|
4541
|
+
req.on('data', (d) => { body += d; });
|
|
4542
|
+
req.on('end', () => {
|
|
4543
|
+
let input;
|
|
4544
|
+
try { input = JSON.parse(body || '{}'); } catch { return json(res, 400, { error: 'bad json' }); }
|
|
4545
|
+
// A boolean and nothing else — same reason as the relay switch: "off"/0/
|
|
4546
|
+
// undefined is how a privacy setting silently becomes the opposite of what
|
|
4547
|
+
// was clicked.
|
|
4548
|
+
if (typeof input?.enabled !== 'boolean') return json(res, 400, { error: 'enabled must be a boolean' });
|
|
4549
|
+
let saved;
|
|
4550
|
+
try { saved = writeAnalyticsPref(DATA_DIR, input.enabled); }
|
|
4551
|
+
catch (e) { return json(res, 500, { error: String(e?.message ?? e) }); }
|
|
4552
|
+
const relayEnabled = remoteAccess().enabled;
|
|
4506
4553
|
return json(res, 200, {
|
|
4507
4554
|
pref: saved,
|
|
4508
|
-
enabled:
|
|
4509
|
-
configured: Boolean(
|
|
4555
|
+
enabled: resolveAnalyticsEnabled({ configured: Boolean(BILLING_API_URL), pref: saved, relayEnabled }),
|
|
4556
|
+
configured: Boolean(BILLING_API_URL),
|
|
4557
|
+
relayEnabled,
|
|
4510
4558
|
});
|
|
4511
4559
|
});
|
|
4512
4560
|
return;
|
package/package.json
CHANGED