@runuai/host 0.2.6 → 0.3.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/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +4 -0
- package/lib/agents/claude.ts +8 -0
- package/lib/agents/codex.ts +11 -0
- package/lib/agents/registry.ts +11 -1
- package/lib/command-db.ts +6 -2
- package/lib/git-diff.ts +67 -16
- package/lib/github-tokens.ts +149 -32
- package/lib/orchestrator.ts +59 -16
- package/lib/preview-sidecar.ts +157 -0
- package/lib/task-diff.ts +75 -20
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +22 -26
- package/src/main.ts +148 -25
- package/src/protocol.ts +28 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- ADR-033: non-expiring per-host tokens. `kind` distinguishes the stored token:
|
|
2
|
+
-- 'refresh' (legacy, ADR-027) → refresh_token_ct holds a refresh token; the
|
|
3
|
+
-- host exchanges it for short-lived access tokens.
|
|
4
|
+
-- 'access' (ADR-033) → refresh_token_ct holds a long-lived, non-
|
|
5
|
+
-- expiring access token; injected directly, no exchange/refresh.
|
|
6
|
+
-- Existing rows default to 'refresh' (their current semantics).
|
|
7
|
+
ALTER TABLE `host_github_tokens` ADD `kind` text DEFAULT 'refresh' NOT NULL;
|
package/db/schema.ts
CHANGED
|
@@ -62,9 +62,13 @@ export type NewHostEventRow = typeof hostEvents.$inferInsert;
|
|
|
62
62
|
export const githubTokens = sqliteTable("host_github_tokens", {
|
|
63
63
|
userId: text("user_id").primaryKey(),
|
|
64
64
|
installationId: integer("installation_id").notNull(),
|
|
65
|
+
// ADR-033: holds a long-lived ACCESS token when kind="access", or a refresh
|
|
66
|
+
// token when kind="refresh" (legacy ADR-027). Encrypted at rest either way.
|
|
65
67
|
refreshTokenCt: blob("refresh_token_ct").notNull(),
|
|
66
68
|
refreshTokenNonce: blob("refresh_token_nonce").notNull(),
|
|
67
69
|
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
|
|
70
|
+
// "access" (non-expiring, ADR-033) | "refresh" (expiring, ADR-027).
|
|
71
|
+
kind: text("kind").notNull().default("refresh"),
|
|
68
72
|
updatedAt: integer("updated_at").notNull(),
|
|
69
73
|
});
|
|
70
74
|
|
package/lib/agents/claude.ts
CHANGED
|
@@ -338,6 +338,14 @@ register({
|
|
|
338
338
|
defaultModel: CLAUDE_DEFAULT_MODEL,
|
|
339
339
|
supportedEfforts: () => [...CLAUDE_EFFORTS],
|
|
340
340
|
defaultEffort: CLAUDE_DEFAULT_EFFORT,
|
|
341
|
+
// Usable only when a Claude credential is in the env (injected into task
|
|
342
|
+
// containers at task-up). Gates advertisement (ADR-044 P2).
|
|
343
|
+
available: () =>
|
|
344
|
+
Boolean(
|
|
345
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
346
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
347
|
+
process.env.ANTHROPIC_AUTH_TOKEN,
|
|
348
|
+
),
|
|
341
349
|
create: async ({ agent, containerName, systemPreamble }) =>
|
|
342
350
|
new ClaudeSession({ agent, containerName, systemPreamble }),
|
|
343
351
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* namespaces inside it anyway.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
31
35
|
import { newId } from "../ulid";
|
|
32
36
|
import { dockerExecArgs, LineProcess } from "./proc";
|
|
33
37
|
import { register } from "./registry";
|
|
@@ -517,6 +521,13 @@ register({
|
|
|
517
521
|
defaultModel: CODEX_DEFAULT_MODEL,
|
|
518
522
|
supportedEfforts: () => [...CODEX_EFFORTS],
|
|
519
523
|
defaultEffort: CODEX_DEFAULT_EFFORT,
|
|
524
|
+
// Usable only when the owner's ~/.codex login exists — task-up copies it into
|
|
525
|
+
// each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
|
|
526
|
+
// (ADR-044 P2).
|
|
527
|
+
available: () =>
|
|
528
|
+
existsSync(
|
|
529
|
+
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
530
|
+
),
|
|
520
531
|
create: async ({ agent, containerName, systemPreamble }) =>
|
|
521
532
|
new CodexSession({ agent, containerName, systemPreamble }),
|
|
522
533
|
});
|
package/lib/agents/registry.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
|
|
|
32
32
|
supportedEfforts(): string[];
|
|
33
33
|
/** Preferred effort when the user doesn't pick one. */
|
|
34
34
|
defaultEffort?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Whether this kind is usable on THIS host right now — i.e. its credentials
|
|
37
|
+
* are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
|
|
38
|
+
* out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
|
|
39
|
+
* host can't actually run. Omit (or return true) to always advertise; the
|
|
40
|
+
* session factory stays permissive regardless.
|
|
41
|
+
*/
|
|
42
|
+
available?: () => boolean;
|
|
35
43
|
/** Builds an AgentSession for a roster entry of this kind. */
|
|
36
44
|
create: AgentSessionFactory["create"];
|
|
37
45
|
}
|
|
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
|
|
|
76
84
|
* Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
|
|
77
85
|
*/
|
|
78
86
|
export function capabilities(): AgentKindCapability[] {
|
|
79
|
-
return list()
|
|
87
|
+
return list()
|
|
88
|
+
.filter((adapter) => adapter.available?.() ?? true)
|
|
89
|
+
.map((adapter) => {
|
|
80
90
|
const out: AgentKindCapability = {
|
|
81
91
|
kind: adapter.kind,
|
|
82
92
|
label: adapter.label,
|
package/lib/command-db.ts
CHANGED
|
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
|
|
|
135
135
|
id, owner_user_id, host_id, name, slug, branch, status,
|
|
136
136
|
global_context, reviewer_order, agents, pr_context,
|
|
137
137
|
worktree_path, compose_project, code_server_port, preview_ports,
|
|
138
|
-
locked_at, started_at, ended_at
|
|
138
|
+
preview_env, locked_at, started_at, ended_at
|
|
139
139
|
) VALUES (
|
|
140
140
|
@id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
|
|
141
141
|
@global_context, @reviewer_order, @agents, @pr_context,
|
|
142
142
|
@worktree_path, @compose_project, @code_server_port, @preview_ports,
|
|
143
|
-
@locked_at, @started_at, @ended_at
|
|
143
|
+
@preview_env, @locked_at, @started_at, @ended_at
|
|
144
144
|
)`,
|
|
145
145
|
).run(task);
|
|
146
146
|
}
|
|
@@ -166,6 +166,7 @@ interface CommandTaskRow {
|
|
|
166
166
|
compose_project: string | null;
|
|
167
167
|
code_server_port: number | null;
|
|
168
168
|
preview_ports: string;
|
|
169
|
+
preview_env: string | null;
|
|
169
170
|
locked_at: number | null;
|
|
170
171
|
started_at: number | null;
|
|
171
172
|
ended_at: number | null;
|
|
@@ -193,6 +194,7 @@ function withRuntime(
|
|
|
193
194
|
compose_project: runtime?.composeProject ?? null,
|
|
194
195
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
195
196
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
197
|
+
preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
|
|
196
198
|
locked_at: runtime?.lockedAt ?? null,
|
|
197
199
|
started_at: runtime?.startedAt ?? null,
|
|
198
200
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
|
|
|
216
218
|
compose_project: runtime?.composeProject ?? null,
|
|
217
219
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
218
220
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
221
|
+
preview_env: null,
|
|
219
222
|
locked_at: runtime?.lockedAt ?? null,
|
|
220
223
|
started_at: runtime?.startedAt ?? null,
|
|
221
224
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
|
|
|
262
265
|
compose_project text,
|
|
263
266
|
code_server_port integer,
|
|
264
267
|
preview_ports text NOT NULL DEFAULT '[]',
|
|
268
|
+
preview_env text,
|
|
265
269
|
pr_url text,
|
|
266
270
|
locked_at integer,
|
|
267
271
|
started_at integer,
|
package/lib/git-diff.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse `git diff` unified output into structured per-file patches.
|
|
3
3
|
*
|
|
4
|
-
* Input is whatever `git diff
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
|
|
5
|
+
* appended `--no-index` new-file patches for untracked files produce —
|
|
6
|
+
* multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
|
|
7
|
+
* The output is a list of `DiffFile`s the UI can render per-file with
|
|
7
8
|
* collapse/expand and syntax-highlighted hunks.
|
|
8
9
|
*
|
|
9
10
|
* Hand-rolled rather than pulled in as a dep — the unified-diff grammar
|
|
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
|
|
|
98
99
|
containerName: string,
|
|
99
100
|
cwd: string,
|
|
100
101
|
range: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
return runDiffCommand(
|
|
104
|
+
"docker",
|
|
105
|
+
[...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
|
|
106
|
+
"docker exec git diff",
|
|
107
|
+
process.env,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render a single untracked file as a new-file patch by diffing it against
|
|
113
|
+
* `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
|
|
114
|
+
* entirely, so this is how they reach the "all changes vs base" view.
|
|
115
|
+
*
|
|
116
|
+
* `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
|
|
117
|
+
* case for a new file) and 0 when identical — both are success here; only
|
|
118
|
+
* code > 1 is a real error.
|
|
119
|
+
*/
|
|
120
|
+
export function runGitDiffUntracked(
|
|
121
|
+
cwd: string,
|
|
122
|
+
relPath: string,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
return runDiffCommand(
|
|
125
|
+
"git",
|
|
126
|
+
["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
|
|
127
|
+
"git diff --no-index",
|
|
128
|
+
{ ...process.env, ...NON_INTERACTIVE_GIT_ENV },
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** In-container variant of {@link runGitDiffUntracked}. */
|
|
134
|
+
export function runGitDiffUntrackedInContainer(
|
|
135
|
+
containerName: string,
|
|
136
|
+
cwd: string,
|
|
137
|
+
relPath: string,
|
|
101
138
|
): Promise<string> {
|
|
102
139
|
return runDiffCommand(
|
|
103
140
|
"docker",
|
|
104
141
|
[
|
|
105
|
-
|
|
106
|
-
"-e",
|
|
107
|
-
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
108
|
-
"-e",
|
|
109
|
-
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
110
|
-
"-e",
|
|
111
|
-
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
112
|
-
"-w",
|
|
113
|
-
cwd,
|
|
114
|
-
containerName,
|
|
142
|
+
...dockerExecPrefix(containerName, cwd),
|
|
115
143
|
"git",
|
|
116
144
|
...GIT_DIFF_ARGS,
|
|
117
|
-
|
|
145
|
+
"--no-index",
|
|
146
|
+
"--",
|
|
147
|
+
"/dev/null",
|
|
148
|
+
relPath,
|
|
118
149
|
],
|
|
119
|
-
"docker exec git diff",
|
|
150
|
+
"docker exec git diff --no-index",
|
|
120
151
|
process.env,
|
|
152
|
+
true,
|
|
121
153
|
);
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
/** `docker exec` argv prefix with the non-interactive git env + working dir. */
|
|
157
|
+
function dockerExecPrefix(containerName: string, cwd: string): string[] {
|
|
158
|
+
return [
|
|
159
|
+
"exec",
|
|
160
|
+
"-e",
|
|
161
|
+
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
162
|
+
"-e",
|
|
163
|
+
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
164
|
+
"-e",
|
|
165
|
+
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
166
|
+
"-w",
|
|
167
|
+
cwd,
|
|
168
|
+
containerName,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function runDiffCommand(
|
|
125
173
|
command: string,
|
|
126
174
|
args: string[],
|
|
127
175
|
label: string,
|
|
128
176
|
env: NodeJS.ProcessEnv,
|
|
177
|
+
/** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
|
|
178
|
+
* compared inputs differ, which for a new-file patch is the expected case. */
|
|
179
|
+
allowExitOne = false,
|
|
129
180
|
): Promise<string> {
|
|
130
181
|
return new Promise((res, rej) => {
|
|
131
182
|
// Force ASCII paths and pull a generous unified context so the UI
|
|
@@ -144,7 +195,7 @@ function runDiffCommand(
|
|
|
144
195
|
});
|
|
145
196
|
child.on("error", rej);
|
|
146
197
|
child.on("close", (code) => {
|
|
147
|
-
if (code === 0) {
|
|
198
|
+
if (code === 0 || (allowExitOne && code === 1)) {
|
|
148
199
|
res(stdout);
|
|
149
200
|
return;
|
|
150
201
|
}
|
package/lib/github-tokens.ts
CHANGED
|
@@ -26,56 +26,101 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
|
|
|
26
26
|
|
|
27
27
|
// --- token store ------------------------------------------------------------
|
|
28
28
|
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt + persist the user's token (gh.connect.set handler). ADR-033: a
|
|
31
|
+
* non-expiring grant carries `accessToken` (stored kind="access", injected
|
|
32
|
+
* directly); a legacy expiring grant carries `refreshToken` (kind="refresh",
|
|
33
|
+
* exchanged per task). Exactly one is present.
|
|
34
|
+
*/
|
|
30
35
|
export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
|
|
31
36
|
try {
|
|
32
37
|
// Fresh grant — a cached access token from the previous grant may be
|
|
33
38
|
// revoked; drop it so the next mint exchanges against the new token.
|
|
34
39
|
clearAccessCache(frame.userId);
|
|
35
|
-
const
|
|
40
|
+
const token = frame.accessToken ?? frame.refreshToken;
|
|
41
|
+
if (!token) return { ok: false, error: "gh.connect.set carried no token" };
|
|
42
|
+
const kind = frame.accessToken ? "access" : "refresh";
|
|
43
|
+
const sealed = sealAesGcm(token);
|
|
36
44
|
const now = Date.now();
|
|
45
|
+
const fields = {
|
|
46
|
+
installationId: frame.installationId,
|
|
47
|
+
refreshTokenCt: sealed.ct,
|
|
48
|
+
refreshTokenNonce: sealed.nonce,
|
|
49
|
+
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
50
|
+
kind,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
};
|
|
37
53
|
getDb()
|
|
38
54
|
.insert(schema.githubTokens)
|
|
39
|
-
.values({
|
|
40
|
-
|
|
41
|
-
installationId: frame.installationId,
|
|
42
|
-
refreshTokenCt: sealed.ct,
|
|
43
|
-
refreshTokenNonce: sealed.nonce,
|
|
44
|
-
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
45
|
-
updatedAt: now,
|
|
46
|
-
})
|
|
47
|
-
.onConflictDoUpdate({
|
|
48
|
-
target: schema.githubTokens.userId,
|
|
49
|
-
set: {
|
|
50
|
-
installationId: frame.installationId,
|
|
51
|
-
refreshTokenCt: sealed.ct,
|
|
52
|
-
refreshTokenNonce: sealed.nonce,
|
|
53
|
-
refreshTokenExpiresAt: frame.refreshTokenExpiresAt ?? null,
|
|
54
|
-
updatedAt: now,
|
|
55
|
-
},
|
|
56
|
-
})
|
|
55
|
+
.values({ userId: frame.userId, ...fields })
|
|
56
|
+
.onConflictDoUpdate({ target: schema.githubTokens.userId, set: fields })
|
|
57
57
|
.run();
|
|
58
|
+
notifyGithubChange();
|
|
58
59
|
return { ok: true };
|
|
59
60
|
} catch (err) {
|
|
60
61
|
return { ok: false, error: err instanceof Error ? err.message : "store failed" };
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Disconnect GitHub on this host (gh.connect.clear handler). Deletes the token
|
|
67
|
+
* locally FIRST (so capabilities re-advertise immediately and the UI reflects
|
|
68
|
+
* removal without waiting on the network), then best-effort revokes it at
|
|
69
|
+
* GitHub. Whole body is guarded — a fired-and-forgotten failure must never
|
|
70
|
+
* crash the host (no global unhandledRejection handler).
|
|
71
|
+
*
|
|
72
|
+
* ADR-033: only a non-expiring ACCESS token can be revoked by token
|
|
73
|
+
* (`DELETE /applications/{client_id}/token` matches access tokens only — a
|
|
74
|
+
* refresh token 404s, which the cloud endpoint would mis-report as success).
|
|
75
|
+
* Legacy refresh tokens aren't single-token revocable; the short-lived access
|
|
76
|
+
* tokens they mint expire on their own.
|
|
77
|
+
*/
|
|
78
|
+
export async function onConnectClear(userId: string): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
const stored = readStoredToken(userId);
|
|
81
|
+
deleteToken(userId); // fires onGithubChange → re-advertise capabilities
|
|
82
|
+
for (const taskId of activeTaskIdsForUser(userId)) {
|
|
83
|
+
clearRefresh(taskId);
|
|
84
|
+
authExpiredHandler?.(taskId, userId, "GitHub disconnected");
|
|
85
|
+
}
|
|
86
|
+
if (stored && stored.kind === "access") {
|
|
87
|
+
await revokeAtGitHub(stored.token);
|
|
88
|
+
} else if (stored) {
|
|
89
|
+
console.warn(
|
|
90
|
+
`[github] user ${userId}: legacy refresh token cleared locally; cannot single-token revoke at GitHub`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[github] disconnect failed: ${err instanceof Error ? err.message : err}`,
|
|
96
|
+
);
|
|
70
97
|
}
|
|
71
98
|
}
|
|
72
99
|
|
|
100
|
+
/** Decrypt the user's stored token + its kind, or null if none. */
|
|
101
|
+
function readStoredToken(
|
|
102
|
+
userId: string,
|
|
103
|
+
): { token: string; kind: string } | null {
|
|
104
|
+
const row = getDb()
|
|
105
|
+
.select()
|
|
106
|
+
.from(schema.githubTokens)
|
|
107
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
108
|
+
.get();
|
|
109
|
+
if (!row) return null;
|
|
110
|
+
const token = openAesGcm(
|
|
111
|
+
Buffer.from(row.refreshTokenCt as Uint8Array),
|
|
112
|
+
Buffer.from(row.refreshTokenNonce as Uint8Array),
|
|
113
|
+
);
|
|
114
|
+
return { token, kind: row.kind };
|
|
115
|
+
}
|
|
116
|
+
|
|
73
117
|
export function deleteToken(userId: string): void {
|
|
74
118
|
clearAccessCache(userId);
|
|
75
119
|
getDb()
|
|
76
120
|
.delete(schema.githubTokens)
|
|
77
121
|
.where(eq(schema.githubTokens.userId, userId))
|
|
78
122
|
.run();
|
|
123
|
+
notifyGithubChange();
|
|
79
124
|
}
|
|
80
125
|
|
|
81
126
|
export function hasToken(userId: string): boolean {
|
|
@@ -88,6 +133,29 @@ export function hasToken(userId: string): boolean {
|
|
|
88
133
|
);
|
|
89
134
|
}
|
|
90
135
|
|
|
136
|
+
/** All cloud user ids with a GitHub token on this host (for capabilities). */
|
|
137
|
+
export function connectedUserIds(): string[] {
|
|
138
|
+
return getDb()
|
|
139
|
+
.select({ userId: schema.githubTokens.userId })
|
|
140
|
+
.from(schema.githubTokens)
|
|
141
|
+
.all()
|
|
142
|
+
.map((r) => r.userId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Fires whenever the set of stored tokens changes (add/remove), so the host
|
|
146
|
+
// can re-advertise capabilities and the UI reflects per-host gh state promptly.
|
|
147
|
+
let githubChangeHandler: (() => void) | null = null;
|
|
148
|
+
export function onGithubChange(fn: (() => void) | null): void {
|
|
149
|
+
githubChangeHandler = fn;
|
|
150
|
+
}
|
|
151
|
+
function notifyGithubChange(): void {
|
|
152
|
+
try {
|
|
153
|
+
githubChangeHandler?.();
|
|
154
|
+
} catch {
|
|
155
|
+
/* never let a listener break token handling */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
91
159
|
function activeTaskIdsForUser(userId: string): string[] {
|
|
92
160
|
return getDb()
|
|
93
161
|
.select({ taskId: schema.hostTasks.taskId })
|
|
@@ -104,12 +172,43 @@ function activeTaskIdsForUser(userId: string): string[] {
|
|
|
104
172
|
|
|
105
173
|
// --- access-token exchange --------------------------------------------------
|
|
106
174
|
|
|
107
|
-
function
|
|
175
|
+
function cloudHttpBase(): string {
|
|
108
176
|
const cloud = process.env.UAI_CLOUD_URL ?? "ws://127.0.0.1:8789/host";
|
|
109
177
|
const u = new URL(cloud);
|
|
110
178
|
const proto =
|
|
111
179
|
u.protocol === "wss:" ? "https:" : u.protocol === "ws:" ? "http:" : u.protocol;
|
|
112
|
-
return `${proto}//${u.host}
|
|
180
|
+
return `${proto}//${u.host}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function exchangeUrl(): string {
|
|
184
|
+
return `${cloudHttpBase()}/api/github/oauth/exchange`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Revoke a token at GitHub via the cloud (which holds the App client secret —
|
|
189
|
+
* the host can't revoke alone). Same host→cloud HTTP pattern as the exchange;
|
|
190
|
+
* the token transits the cloud transiently, never stored (ADR-015/ADR-033).
|
|
191
|
+
* Best-effort — callers proceed to delete locally regardless.
|
|
192
|
+
*/
|
|
193
|
+
async function revokeAtGitHub(token: string): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(`${cloudHttpBase()}/api/github/revoke`, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: {
|
|
198
|
+
"content-type": "application/json",
|
|
199
|
+
"x-uai-host-token": process.env.UAI_HOST_TOKEN ?? "",
|
|
200
|
+
},
|
|
201
|
+
body: JSON.stringify({ token }),
|
|
202
|
+
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
console.warn(`[github] token revoke returned HTTP ${res.status}`);
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.warn(
|
|
209
|
+
`[github] token revoke failed (deleting locally anyway): ${err instanceof Error ? err.message : err}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
113
212
|
}
|
|
114
213
|
|
|
115
214
|
interface ExchangeResult {
|
|
@@ -165,7 +264,23 @@ export function clearAllAccessCache(): void {
|
|
|
165
264
|
|
|
166
265
|
export function requestAccessToken(
|
|
167
266
|
userId: string,
|
|
168
|
-
): Promise<{ accessToken: string; expiresAt: number } | null> {
|
|
267
|
+
): Promise<{ accessToken: string; expiresAt: number | null } | null> {
|
|
268
|
+
// ADR-033 non-expiring path: the stored token IS the access token — return it
|
|
269
|
+
// directly, no exchange/cache/rotation. `expiresAt: null` ⇒ no refresh.
|
|
270
|
+
const row = getDb()
|
|
271
|
+
.select({ kind: schema.githubTokens.kind })
|
|
272
|
+
.from(schema.githubTokens)
|
|
273
|
+
.where(eq(schema.githubTokens.userId, userId))
|
|
274
|
+
.get();
|
|
275
|
+
if (!row) return Promise.resolve(null);
|
|
276
|
+
if (row.kind === "access") {
|
|
277
|
+
const stored = readStoredToken(userId);
|
|
278
|
+
return Promise.resolve(
|
|
279
|
+
stored ? { accessToken: stored.token, expiresAt: null } : null,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Legacy expiring path (ADR-027): cache + in-flight dedupe + exchange.
|
|
169
284
|
const cached = accessCache.get(userId);
|
|
170
285
|
if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
|
|
171
286
|
return Promise.resolve(cached);
|
|
@@ -327,7 +442,8 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
|
327
442
|
return;
|
|
328
443
|
}
|
|
329
444
|
await injectIntoContainer(taskId, tok.accessToken);
|
|
330
|
-
|
|
445
|
+
// Only re-schedule for an expiring token; non-expiring needs no refresh.
|
|
446
|
+
if (tok.expiresAt !== null) scheduleRefresh(taskId, userId, tok.expiresAt);
|
|
331
447
|
} catch (err) {
|
|
332
448
|
const reason = err instanceof Error ? err.message : String(err);
|
|
333
449
|
// A revoked / expired refresh token can't recover — drop it to force a
|
|
@@ -423,7 +539,7 @@ export interface SetupTaskDeps {
|
|
|
423
539
|
hasToken?: (userId: string) => boolean;
|
|
424
540
|
requestAccessToken?: (
|
|
425
541
|
userId: string,
|
|
426
|
-
) => Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
542
|
+
) => Promise<{ accessToken: string; expiresAt: number | null } | null>;
|
|
427
543
|
inject?: (taskId: string, token: string) => void | Promise<void>;
|
|
428
544
|
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
429
545
|
deleteToken?: (userId: string) => void;
|
|
@@ -460,7 +576,8 @@ export async function setupTaskGithub(
|
|
|
460
576
|
const tok = await _request(userId);
|
|
461
577
|
if (tok) {
|
|
462
578
|
await _inject(taskId, tok.accessToken);
|
|
463
|
-
|
|
579
|
+
// Non-expiring (ADR-033) tokens (expiresAt null) need no refresh timer.
|
|
580
|
+
if (tok.expiresAt !== null) _schedule(taskId, userId, tok.expiresAt);
|
|
464
581
|
clearGithubRetry(taskId);
|
|
465
582
|
console.log(`[github] task ${taskId}: injected user access token`);
|
|
466
583
|
return true;
|
package/lib/orchestrator.ts
CHANGED
|
@@ -235,14 +235,13 @@ class Orchestrator {
|
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
238
|
+
// First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
|
|
239
|
+
// which fires each agent's initialPrompt exactly once per task — guarded by
|
|
240
|
+
// a non-empty message history (its durable "already started" check). The
|
|
241
|
+
// host must NOT deliver it here: this path re-runs on every session spawn,
|
|
242
|
+
// so it both duplicated the opening turn AND re-injected it on a host
|
|
243
|
+
// restart, making agents redo the first turn mid-task. The persona/mission
|
|
244
|
+
// an agent needs live in the always-on system preamble, so nothing is lost.
|
|
246
245
|
return true;
|
|
247
246
|
}
|
|
248
247
|
|
|
@@ -723,7 +722,11 @@ export function buildSystemPreamble(
|
|
|
723
722
|
workspacePath: string,
|
|
724
723
|
taskBranch: string,
|
|
725
724
|
): string {
|
|
726
|
-
const
|
|
725
|
+
const channelList = roster
|
|
726
|
+
.map((a) =>
|
|
727
|
+
a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
|
|
728
|
+
)
|
|
729
|
+
.join(", ");
|
|
727
730
|
const projectLines =
|
|
728
731
|
projects.length === 0
|
|
729
732
|
? ["(none mounted)"]
|
|
@@ -739,12 +742,50 @@ export function buildSystemPreamble(
|
|
|
739
742
|
"it by id at the start of a line — e.g. `@codex please review the",
|
|
740
743
|
"diff`. uai routes that message into that agent's input.",
|
|
741
744
|
"",
|
|
742
|
-
|
|
745
|
+
`**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
|
|
746
|
+
`Introduce and refer to yourself as ${agent.label}, not as a generic`,
|
|
747
|
+
"assistant or model name.",
|
|
748
|
+
"",
|
|
749
|
+
`Agents in this channel: ${channelList}.`,
|
|
750
|
+
"",
|
|
751
|
+
"The human you're working with is **@you**. @-mentioning them sends a",
|
|
752
|
+
"NOTIFICATION, so use it sparingly — only when you actually need them: a",
|
|
753
|
+
"decision you can't make, a blocker, an approval, or you've finished your",
|
|
754
|
+
"work and are handing it back for them to act on (e.g. `@you which`",
|
|
755
|
+
"`approach do you prefer?` or `@you done — PR is up for review`). For",
|
|
756
|
+
"everything else — status updates, thinking out loud, a direct reply to",
|
|
757
|
+
"something they just asked, acknowledgments — post in the channel WITHOUT",
|
|
758
|
+
"@-mentioning @you; they can read the channel and don't need a ping for",
|
|
759
|
+
"every message. Do NOT reflexively end messages with @you.",
|
|
743
760
|
"",
|
|
744
761
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
745
|
-
"(or addressed by the human) — so always @-mention the agent you
|
|
746
|
-
"There is NO `peer` command and no shared tmux session;
|
|
747
|
-
"just @-mentions in your replies.",
|
|
762
|
+
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
763
|
+
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
764
|
+
"hand-offs are just @-mentions in your replies.",
|
|
765
|
+
"",
|
|
766
|
+
"Collaborate with your peers — divide up the work, review each other's",
|
|
767
|
+
"changes, share concrete ideas, and debate approach decisions by",
|
|
768
|
+
"@-mentioning them. That is how the team gets things done, and you",
|
|
769
|
+
"should do it freely whenever it moves the work forward. But mentioning",
|
|
770
|
+
"a peer WAKES it and costs a turn, so make each one count: a message to",
|
|
771
|
+
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
772
|
+
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
773
|
+
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
774
|
+
"you're ready — content-free replies wake them for nothing and spiral",
|
|
775
|
+
"into endless back-and-forth. If you have nothing substantive to add,",
|
|
776
|
+
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
777
|
+
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
778
|
+
"need to @-mention anyone (including @you); they can see the channel.",
|
|
779
|
+
"",
|
|
780
|
+
"When a message already @-mentions several participants at once (the",
|
|
781
|
+
"human asking the whole group, or a peer addressing multiple agents),",
|
|
782
|
+
"it's a group broadcast — this is a GROUP CHAT and everyone named has",
|
|
783
|
+
"ALREADY been notified and will answer for themselves. Just answer for",
|
|
784
|
+
"YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
|
|
785
|
+
"question to them, or wait on them — no `I'll let @x speak`, `@x your",
|
|
786
|
+
"turn`, or `still waiting on @x`. Re-mentioning someone who already got",
|
|
787
|
+
"the message only wakes them again and spirals into duplicate replies.",
|
|
788
|
+
"Say your piece and stop.",
|
|
748
789
|
"",
|
|
749
790
|
"Because your input is only what you're addressed, you may be missing",
|
|
750
791
|
"context from messages between the human and the other agents. The full",
|
|
@@ -757,9 +798,11 @@ export function buildSystemPreamble(
|
|
|
757
798
|
"and committed your changes, or completed a review, end your reply by",
|
|
758
799
|
"@-mentioning the agent who should act next and telling them what you",
|
|
759
800
|
"did and what you need (e.g. `@codex changes committed on <branch> —",
|
|
760
|
-
"please review`, or `@claude review done, N issues to fix`).
|
|
761
|
-
"
|
|
762
|
-
"
|
|
801
|
+
"please review`, or `@claude review done, N issues to fix`). Don't",
|
|
802
|
+
"abandon unfinished work silently — but once your part is done and no",
|
|
803
|
+
"peer needs to act, it's fine to stop; only @-mention @you if you need",
|
|
804
|
+
"their input or are handing back finished work for them to act on. Don't",
|
|
805
|
+
"prolong an agent-to-agent exchange just to fill silence.",
|
|
763
806
|
"",
|
|
764
807
|
"## Workspace layout",
|
|
765
808
|
"",
|