Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
fbd9a1a199 fix(docs): restore Tess markdown formatting gate
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-12 14:55:14 -05:00
13 changed files with 22 additions and 281 deletions

View File

@@ -13,7 +13,6 @@ import { Server, Socket } from 'socket.io';
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent'; import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
import type { Auth } from '@mosaicstack/auth'; import type { Auth } from '@mosaicstack/auth';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import { redactSensitiveContent } from '@mosaicstack/log';
import type { import type {
SetThinkingPayload, SetThinkingPayload,
SlashCommandPayload, SlashCommandPayload,
@@ -211,10 +210,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ {
conversationId, conversationId,
role: 'user', role: 'user',
content: redactSensitiveContent(data.content).content, content: data.content,
metadata: { metadata: {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
classifications: redactSensitiveContent(data.content).classifications,
}, },
}, },
userId, userId,
@@ -613,11 +611,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
{ {
conversationId, conversationId,
role: 'assistant', role: 'assistant',
content: redactSensitiveContent(cs.assistantText).content, content: cs.assistantText,
metadata: { metadata,
...metadata,
classifications: redactSensitiveContent(cs.assistantText).classifications,
},
}, },
userId, userId,
) )
@@ -641,18 +636,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
if (assistantEvent.type === 'text_delta') { if (assistantEvent.type === 'text_delta') {
// Accumulate assistant text for persistence // Accumulate assistant text for persistence
const cs = this.clientSessions.get(client.id); const cs = this.clientSessions.get(client.id);
const redactedDelta = redactSensitiveContent(assistantEvent.delta).content;
if (cs) { if (cs) {
cs.assistantText += redactedDelta; cs.assistantText += assistantEvent.delta;
} }
client.emit('agent:text', { client.emit('agent:text', {
conversationId, conversationId,
text: redactedDelta, text: assistantEvent.delta,
}); });
} else if (assistantEvent.type === 'thinking_delta') { } else if (assistantEvent.type === 'thinking_delta') {
client.emit('agent:thinking', { client.emit('agent:thinking', {
conversationId, conversationId,
text: redactSensitiveContent(assistantEvent.delta).content, text: assistantEvent.delta,
}); });
} }
break; break;

View File

@@ -105,8 +105,8 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.command).toBe('provider'); expect(result.command).toBe('provider');
}); });
// /provider login anthropic — no bearer token or auth URL reaches chat output // /provider login anthropic — success with URL containing poll token
it('/provider login <name> keeps its one-time token out of chat output', async () => { it('/provider login <name> returns success with URL and poll token', async () => {
const payload: SlashCommandPayload = { const payload: SlashCommandPayload = {
command: 'provider', command: 'provider',
args: 'login anthropic', args: 'login anthropic',
@@ -116,9 +116,14 @@ describe('CommandExecutorService — P8-012 commands', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.command).toBe('provider'); expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic'); expect(result.message).toContain('anthropic');
expect(result.message).not.toContain('http'); expect(result.message).toContain('http');
expect(result.message).not.toContain('token='); // data should contain loginUrl and pollToken
expect(result.data).toEqual({ provider: 'anthropic' }); expect(result.data).toBeDefined();
const data = result.data as Record<string, unknown>;
expect(typeof data['loginUrl']).toBe('string');
expect(typeof data['pollToken']).toBe('string');
expect(data['loginUrl'] as string).toContain('anthropic');
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
// Verify Valkey was called // Verify Valkey was called
expect(mockRedis.set).toHaveBeenCalledOnce(); expect(mockRedis.set).toHaveBeenCalledOnce();
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];

View File

@@ -403,28 +403,22 @@ export class CommandExecutorService {
}; };
} }
const pollToken = crypto.randomUUID(); const pollToken = crypto.randomUUID();
const tokenDigest = await crypto.subtle.digest( const key = `mosaic:auth:poll:${pollToken}`;
'SHA-256', // Store pending state in Valkey (TTL 5 minutes)
new TextEncoder().encode(pollToken),
);
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
byte.toString(16).padStart(2, '0'),
).join('');
const key = `mosaic:auth:poll:${tokenHash}`;
// Persist only a short-lived token digest. The raw token is delivered only by
// the authenticated dashboard flow, never in chat output or command metadata.
await this.redis.set( await this.redis.set(
key, key,
JSON.stringify({ status: 'pending', provider: providerName, userId }), JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX', 'EX',
300, 300,
); );
// In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return { return {
command: 'provider', command: 'provider',
success: true, success: true,
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
conversationId, conversationId,
data: { provider: providerName }, data: { loginUrl, pollToken, provider: providerName },
}; };
} }

View File

@@ -1,49 +0,0 @@
# #703 Git Wrapper Interactive and Auth Resilience
## Objective
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
## Scope
- `packages/mosaic/framework/tools/git/issue-create.sh`
- `packages/mosaic/framework/tools/git/detect-platform.sh`
- Git wrapper regression harnesses
- This scratchpad
## Requirements / acceptance evidence
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
2. Explicit command-line fields retain precedence and do not trigger prompt input.
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
## Plan
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
2. Implement minimal helper and parser changes.
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
4. Re-seed deployed framework and run live wrapper verification.
5. Commit, queue guard, push, open PR, and stop for independent review.
## Progress
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
## Verification
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
- PASS: Prettier check for this scratchpad

View File

@@ -10,8 +10,3 @@ export {
type LogQuery, type LogQuery,
} from './agent-logs.js'; } from './agent-logs.js';
export { registerLogCommand } from './cli.js'; export { registerLogCommand } from './cli.js';
export {
redactSensitiveContent,
type RedactionResult,
type SensitiveClassification,
} from './redaction.js';

View File

@@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest';
import { redactSensitiveContent } from './redaction.js';
describe('redactSensitiveContent', (): void => {
it('redacts seeded secret and PII canaries before persistence or egress', (): void => {
const result = redactSensitiveContent(
'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212',
);
expect(result.content).not.toContain('canary@example.test');
expect(result.content).not.toContain('sk_CANARY12345678');
expect(result.content).not.toContain('+1 555 555 1212');
expect(result.classifications).toEqual(['secret', 'pii']);
});
});

View File

@@ -1,35 +0,0 @@
export type SensitiveClassification = 'secret' | 'pii';
export interface RedactionResult {
content: string;
classifications: SensitiveClassification[];
}
const SECRET_PATTERNS: RegExp[] = [
/\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g,
/\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi,
];
const PII_PATTERNS: RegExp[] = [
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
/\b\+?\d[\d(). -]{7,}\d\b/g,
];
export function redactSensitiveContent(content: string): RedactionResult {
let redacted = content;
const classifications: SensitiveClassification[] = [];
for (const pattern of SECRET_PATTERNS) {
if (pattern.test(redacted)) {
classifications.push('secret');
redacted = redacted.replace(pattern, '[REDACTED_SECRET]');
}
pattern.lastIndex = 0;
}
for (const pattern of PII_PATTERNS) {
if (pattern.test(redacted)) {
classifications.push('pii');
redacted = redacted.replace(pattern, '[REDACTED_PII]');
}
pattern.lastIndex = 0;
}
return { content: redacted, classifications: [...new Set(classifications)] };
}

View File

@@ -240,33 +240,6 @@ get_gitea_login_for_host() {
return 1 return 1
} }
# Validate the current authenticated Gitea user for a resolved Tea login.
# Tea stores a user name with each login which can become stale after user rename,
# token rotation, or server migration. Querying /user derives the identity from the
# active credential instead of trusting that saved name. Callers fall back to the
# host-scoped API path when this validation fails.
get_gitea_authenticated_user() {
local login_name="$1" response
command -v tea >/dev/null 2>&1 || return 1
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
import json
import os
try:
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
except (KeyError, json.JSONDecodeError):
raise SystemExit(1)
login = user.get("login") if isinstance(user, dict) else None
if isinstance(login, str) and login:
print(login)
raise SystemExit(0)
raise SystemExit(1)
PY
}
get_default_tea_login() { get_default_tea_login() {
local logins_json local logins_json

View File

@@ -12,7 +12,6 @@ TITLE=""
BODY="" BODY=""
LABELS="" LABELS=""
MILESTONE="" MILESTONE=""
INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh # get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -67,13 +66,11 @@ Options:
-b, --body BODY Issue body/description -b, --body BODY Issue body/description
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature") -l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
-m, --milestone NAME Milestone name to assign -m, --milestone NAME Milestone name to assign
-i, --interactive Prompt for missing issue fields
-h, --help Show this help message -h, --help Show this help message
Examples: Examples:
$(basename "$0") -t "Fix login bug" -l "bug,priority-high" $(basename "$0") -t "Fix login bug" -l "bug,priority-high"
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0" $(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
$(basename "$0") -i
EOF EOF
exit "${1:-1}" exit "${1:-1}"
} }
@@ -97,10 +94,6 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2" MILESTONE="$2"
shift 2 shift 2
;; ;;
-i|--interactive)
INTERACTIVE=true
shift
;;
-h|--help) -h|--help)
usage 0 usage 0
;; ;;
@@ -111,13 +104,6 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
if [[ "$INTERACTIVE" == true ]]; then
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
fi
if [[ -z "$TITLE" ]]; then if [[ -z "$TITLE" ]]; then
echo "Error: Title is required (-t)" >&2 echo "Error: Title is required (-t)" >&2
usage usage
@@ -141,11 +127,6 @@ case "$PLATFORM" in
gitea_issue_create_api gitea_issue_create_api
exit $? exit $?
} }
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_issue_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE") CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -183,11 +183,6 @@ case "$PLATFORM" in
gitea_pr_create_api gitea_pr_create_api
exit $? exit $?
} }
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
gitea_pr_create_api
exit $?
fi
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME") REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE") CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY") [[ -n "$BODY" ]] && CMD+=(--description "$BODY")

View File

@@ -45,11 +45,6 @@ JSON
exit 0 exit 0
fi fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG" printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
echo 'GetUserByName: simulated stale login failure' >&2 echo 'GetUserByName: simulated stale login failure' >&2

View File

@@ -55,11 +55,6 @@ JSON
exit 0 exit 0
fi fi
if [[ "${1:-}" == "api" ]]; then
printf '%s\n' '{"login":"ci-bot"}'
exit 0
fi
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
desc="" desc=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do

View File

@@ -1,88 +0,0 @@
#!/usr/bin/env bash
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
JSON
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
echo 'GetUserByName: stale configured user' >&2
exit 1
fi
printf '%s\n' '{"login":"current-user"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
# Explicit values take precedence in interactive mode: no title input is
# supplied, but the wrapper still creates the issue with the explicit title.
: > "$LOG_FILE"
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
if grep -q -- 'tea issue create' "$LOG_FILE"; then
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
if grep -q -- 'tea pr create' "$LOG_FILE"; then
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
echo 'issue-create interactive/auth regression harness passed'